@empire-builder-kit/nx 1.0.0-beta.9 → 1.0.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +6 -0
- package/README.md +113 -3
- package/executors.json +30 -0
- package/package.json +84 -6
- package/src/api-contract/baseline-update.d.ts +82 -0
- package/src/api-contract/baseline-update.js +225 -0
- package/src/api-contract/baseline-update.js.map +1 -0
- package/src/api-contract/compatibility.d.ts +30 -0
- package/src/api-contract/compatibility.js +643 -0
- package/src/api-contract/compatibility.js.map +1 -0
- package/src/api-contract/contract-graph.d.ts +35 -0
- package/src/api-contract/contract-graph.js +141 -0
- package/src/api-contract/contract-graph.js.map +1 -0
- package/src/api-contract/evidence.d.ts +23 -0
- package/src/api-contract/evidence.js +87 -0
- package/src/api-contract/evidence.js.map +1 -0
- package/src/api-contract/index.d.ts +6 -0
- package/src/api-contract/index.js +10 -0
- package/src/api-contract/index.js.map +1 -0
- package/src/api-contract/model.d.ts +144 -0
- package/src/api-contract/model.fixtures.d.ts +2 -0
- package/src/api-contract/model.fixtures.js +81 -0
- package/src/api-contract/model.fixtures.js.map +1 -0
- package/src/api-contract/model.js +993 -0
- package/src/api-contract/model.js.map +1 -0
- package/src/api-contract/rollout.d.ts +32 -0
- package/src/api-contract/rollout.js +88 -0
- package/src/api-contract/rollout.js.map +1 -0
- package/src/authorization/compatibility.d.ts +62 -0
- package/src/authorization/compatibility.js +175 -0
- package/src/authorization/compatibility.js.map +1 -0
- package/src/authorization/index.d.ts +1 -0
- package/src/authorization/index.js +5 -0
- package/src/authorization/index.js.map +1 -0
- package/src/blueprints/execution.d.ts +25 -1
- package/src/blueprints/execution.js +246 -83
- package/src/blueprints/execution.js.map +1 -1
- package/src/blueprints/external-sandbox.d.ts +53 -0
- package/src/blueprints/external-sandbox.js +1182 -0
- package/src/blueprints/external-sandbox.js.map +1 -0
- package/src/blueprints/manifest.d.ts +42 -2
- package/src/blueprints/manifest.js +82 -27
- package/src/blueprints/manifest.js.map +1 -1
- package/src/blueprints/resolver.d.ts +14 -5
- package/src/blueprints/resolver.js +228 -68
- package/src/blueprints/resolver.js.map +1 -1
- package/src/cli/agent-command.d.ts +30 -0
- package/src/cli/agent-command.js +204 -0
- package/src/cli/agent-command.js.map +1 -0
- package/src/cli/agent-runtime.d.ts +25 -0
- package/src/cli/agent-runtime.js +488 -0
- package/src/cli/agent-runtime.js.map +1 -0
- package/src/cli/blueprint-authority.d.ts +9 -0
- package/src/cli/blueprint-authority.js +43 -0
- package/src/cli/blueprint-authority.js.map +1 -0
- package/src/cli/blueprint-command.d.ts +24 -0
- package/src/cli/blueprint-command.js +63 -0
- package/src/cli/blueprint-command.js.map +1 -0
- package/src/cli/bootstrap.d.ts +31 -0
- package/src/cli/bootstrap.js +165 -0
- package/src/cli/bootstrap.js.map +1 -0
- package/src/cli/dev-command.d.ts +92 -0
- package/src/cli/dev-command.js +561 -0
- package/src/cli/dev-command.js.map +1 -0
- package/src/cli/main.d.ts +131 -0
- package/src/cli/main.js +1199 -0
- package/src/cli/main.js.map +1 -0
- package/src/cli/output.d.ts +19 -0
- package/src/cli/output.js +51 -0
- package/src/cli/output.js.map +1 -0
- package/src/cli/personal-secret-runtime.d.ts +81 -0
- package/src/cli/personal-secret-runtime.js +567 -0
- package/src/cli/personal-secret-runtime.js.map +1 -0
- package/src/cli/secret-terminal.d.ts +12 -0
- package/src/cli/secret-terminal.js +128 -0
- package/src/cli/secret-terminal.js.map +1 -0
- package/src/container-images.d.ts +1 -0
- package/src/container-images.js +5 -0
- package/src/container-images.js.map +1 -0
- package/src/data-lifecycle/index.d.ts +3 -0
- package/src/data-lifecycle/index.js +7 -0
- package/src/data-lifecycle/index.js.map +1 -0
- package/src/data-lifecycle/model.d.ts +172 -0
- package/src/data-lifecycle/model.fixtures.d.ts +2 -0
- package/src/data-lifecycle/model.fixtures.js +244 -0
- package/src/data-lifecycle/model.fixtures.js.map +1 -0
- package/src/data-lifecycle/model.js +95 -0
- package/src/data-lifecycle/model.js.map +1 -0
- package/src/data-lifecycle/sensitive-artifacts.d.ts +28 -0
- package/src/data-lifecycle/sensitive-artifacts.js +146 -0
- package/src/data-lifecycle/sensitive-artifacts.js.map +1 -0
- package/src/data-lifecycle/validation.d.ts +32 -0
- package/src/data-lifecycle/validation.js +837 -0
- package/src/data-lifecycle/validation.js.map +1 -0
- package/src/database-retirement/contract.d.ts +68 -0
- package/src/database-retirement/contract.js +166 -0
- package/src/database-retirement/contract.js.map +1 -0
- package/src/database-retirement/index.d.ts +1 -0
- package/src/database-retirement/index.js +5 -0
- package/src/database-retirement/index.js.map +1 -0
- package/src/deterministic-order.d.ts +2 -0
- package/src/deterministic-order.js +7 -0
- package/src/deterministic-order.js.map +1 -0
- package/src/developer-agent/context.d.ts +27 -0
- package/src/developer-agent/context.js +497 -0
- package/src/developer-agent/context.js.map +1 -0
- package/src/developer-agent/generator-planning.d.ts +34 -0
- package/src/developer-agent/generator-planning.js +645 -0
- package/src/developer-agent/generator-planning.js.map +1 -0
- package/src/developer-agent/generator-preview.d.ts +8 -0
- package/src/developer-agent/generator-preview.js +650 -0
- package/src/developer-agent/generator-preview.js.map +1 -0
- package/src/developer-agent/git-inspection.d.ts +108 -0
- package/src/developer-agent/git-inspection.js +815 -0
- package/src/developer-agent/git-inspection.js.map +1 -0
- package/src/developer-agent/handoff.d.ts +43 -0
- package/src/developer-agent/handoff.js +698 -0
- package/src/developer-agent/handoff.js.map +1 -0
- package/src/developer-agent/index.d.ts +10 -0
- package/src/developer-agent/index.js +14 -0
- package/src/developer-agent/index.js.map +1 -0
- package/src/developer-agent/model.d.ts +481 -0
- package/src/developer-agent/model.js +143 -0
- package/src/developer-agent/model.js.map +1 -0
- package/src/developer-agent/operation-registry.d.ts +45 -0
- package/src/developer-agent/operation-registry.js +1255 -0
- package/src/developer-agent/operation-registry.js.map +1 -0
- package/src/developer-agent/ordering.d.ts +9 -0
- package/src/developer-agent/ordering.js +15 -0
- package/src/developer-agent/ordering.js.map +1 -0
- package/src/developer-agent/planning.d.ts +18 -0
- package/src/developer-agent/planning.js +447 -0
- package/src/developer-agent/planning.js.map +1 -0
- package/src/developer-agent/preview-changes.d.ts +26 -0
- package/src/developer-agent/preview-changes.js +203 -0
- package/src/developer-agent/preview-changes.js.map +1 -0
- package/src/developer-agent/preview-source.d.ts +2 -0
- package/src/developer-agent/preview-source.js +16 -0
- package/src/developer-agent/preview-source.js.map +1 -0
- package/src/developer-agent/validation.d.ts +18 -0
- package/src/developer-agent/validation.js +2617 -0
- package/src/developer-agent/validation.js.map +1 -0
- package/src/developer-agent/workspace-inspection.d.ts +20 -0
- package/src/developer-agent/workspace-inspection.js +1134 -0
- package/src/developer-agent/workspace-inspection.js.map +1 -0
- package/src/developer-agent/workspace-path.d.ts +6 -0
- package/src/developer-agent/workspace-path.js +22 -0
- package/src/developer-agent/workspace-path.js.map +1 -0
- package/src/executors/blueprint-trust/blueprint-trust.d.ts +29 -0
- package/src/executors/blueprint-trust/blueprint-trust.js +115 -0
- package/src/executors/blueprint-trust/blueprint-trust.js.map +1 -0
- package/src/executors/blueprint-trust/schema.json +27 -0
- package/src/executors/blueprint-verification/blueprint-verification.d.ts +13 -0
- package/src/executors/blueprint-verification/blueprint-verification.js +110 -0
- package/src/executors/blueprint-verification/blueprint-verification.js.map +1 -0
- package/src/executors/blueprint-verification/schema.json +44 -0
- package/src/executors/deployment-plan/deployment-plan.js +1 -0
- package/src/executors/deployment-plan/deployment-plan.js.map +1 -1
- package/src/executors/dev-doctor/dev-doctor.d.ts +19 -5
- package/src/executors/dev-doctor/dev-doctor.js +186 -32
- package/src/executors/dev-doctor/dev-doctor.js.map +1 -1
- package/src/executors/dev-doctor/schema.json +1 -6
- package/src/executors/dev-setup/dev-setup.d.ts +7 -1
- package/src/executors/dev-setup/dev-setup.js +14 -3
- package/src/executors/dev-setup/dev-setup.js.map +1 -1
- package/src/executors/developer-context/developer-context.d.ts +38 -0
- package/src/executors/developer-context/developer-context.js +78 -0
- package/src/executors/developer-context/developer-context.js.map +1 -0
- package/src/executors/developer-context/schema.json +87 -0
- package/src/executors/fleet/fleet.d.ts +12 -43
- package/src/executors/fleet/fleet.js +120 -500
- package/src/executors/fleet/fleet.js.map +1 -1
- package/src/executors/fleet/schema.json +64 -14
- package/src/executors/github-bootstrap/github-bootstrap.d.ts +1 -0
- package/src/executors/github-bootstrap/github-bootstrap.js +1 -0
- package/src/executors/github-bootstrap/github-bootstrap.js.map +1 -1
- package/src/executors/github-bootstrap/schema.json +5 -0
- package/src/executors/infrastructure-plan/infrastructure-plan.d.ts +144 -0
- package/src/executors/infrastructure-plan/infrastructure-plan.js +3320 -0
- package/src/executors/infrastructure-plan/infrastructure-plan.js.map +1 -0
- package/src/executors/infrastructure-plan/schema.json +78 -0
- package/src/executors/secret-guardrails/schema.json +6 -0
- package/src/executors/secret-guardrails/secret-guardrails.d.ts +3 -0
- package/src/executors/secret-guardrails/secret-guardrails.js +44 -0
- package/src/executors/secret-guardrails/secret-guardrails.js.map +1 -0
- package/src/executors/secret-metadata/schema.json +31 -0
- package/src/executors/secret-metadata/secret-metadata.d.ts +32 -0
- package/src/executors/secret-metadata/secret-metadata.js +131 -0
- package/src/executors/secret-metadata/secret-metadata.js.map +1 -0
- package/src/executors/sst-lifecycle/logging-preflight.d.ts +17 -0
- package/src/executors/sst-lifecycle/logging-preflight.js +196 -0
- package/src/executors/sst-lifecycle/logging-preflight.js.map +1 -0
- package/src/executors/sst-lifecycle/run-sst-command.d.ts +23 -3
- package/src/executors/sst-lifecycle/run-sst-command.js +113 -10
- package/src/executors/sst-lifecycle/run-sst-command.js.map +1 -1
- package/src/executors/sst-lifecycle/schema.json +9 -5
- package/src/executors/sst-lifecycle/secret-preflight.d.ts +25 -0
- package/src/executors/sst-lifecycle/secret-preflight.js +119 -0
- package/src/executors/sst-lifecycle/secret-preflight.js.map +1 -0
- package/src/foundation/discovery.d.ts +17 -1
- package/src/foundation/discovery.js +16 -1
- package/src/foundation/discovery.js.map +1 -1
- package/src/foundation/index.d.ts +3 -0
- package/src/foundation/index.js +3 -0
- package/src/foundation/index.js.map +1 -1
- package/src/foundation/local-database-contract.d.ts +1 -0
- package/src/foundation/local-database-contract.js +3 -0
- package/src/foundation/local-database-contract.js.map +1 -1
- package/src/foundation/logging-preflight.d.ts +30 -0
- package/src/foundation/logging-preflight.js +138 -0
- package/src/foundation/logging-preflight.js.map +1 -0
- package/src/foundation/logging.d.ts +70 -0
- package/src/foundation/logging.js +133 -0
- package/src/foundation/logging.js.map +1 -0
- package/src/foundation/preflight.d.ts +9 -0
- package/src/foundation/preflight.js +58 -2
- package/src/foundation/preflight.js.map +1 -1
- package/src/foundation/provision-database-bindings.d.ts +9 -1
- package/src/foundation/provision-database-bindings.js +25 -5
- package/src/foundation/provision-database-bindings.js.map +1 -1
- package/src/foundation/provision-logical-database.d.ts +10 -2
- package/src/foundation/provision-logical-database.js +68 -113
- package/src/foundation/provision-logical-database.js.map +1 -1
- package/src/foundation/router-origin-proof.d.ts +2 -0
- package/src/foundation/router-origin-proof.js +25 -0
- package/src/foundation/router-origin-proof.js.map +1 -0
- package/src/foundation/stages.d.ts +1 -0
- package/src/foundation/stages.js +9 -1
- package/src/foundation/stages.js.map +1 -1
- package/src/frontend-security/index.d.ts +1 -0
- package/src/frontend-security/index.js +5 -0
- package/src/frontend-security/index.js.map +1 -0
- package/src/frontend-security/security-headers.d.ts +50 -0
- package/src/frontend-security/security-headers.js +418 -0
- package/src/frontend-security/security-headers.js.map +1 -0
- package/src/generators/feature-registry.d.ts +13 -0
- package/src/generators/feature-registry.js +73 -0
- package/src/generators/feature-registry.js.map +1 -0
- package/src/generators/function/function.d.ts +1 -1
- package/src/generators/function/function.js +51 -6
- package/src/generators/function/function.js.map +1 -1
- package/src/generators/function/schema.d.ts +1 -0
- package/src/generators/function/schema.json +6 -1
- package/src/generators/logging-registry.d.ts +13 -0
- package/src/generators/logging-registry.js +62 -0
- package/src/generators/logging-registry.js.map +1 -0
- package/src/generators/post-generation-install.d.ts +67 -0
- package/src/generators/post-generation-install.js +3308 -0
- package/src/generators/post-generation-install.js.map +1 -0
- package/src/generators/preset/generator.js +3 -0
- package/src/generators/preset/generator.js.map +1 -1
- package/src/generators/preset/tool-versions.d.ts +18 -0
- package/src/generators/preset/tool-versions.js +22 -0
- package/src/generators/preset/tool-versions.js.map +1 -0
- package/src/generators/preset/workspace-files-developer-agent.d.ts +14 -0
- package/src/generators/preset/workspace-files-developer-agent.js +511 -0
- package/src/generators/preset/workspace-files-developer-agent.js.map +1 -0
- package/src/generators/preset/workspace-files-foundation-workload-roles.d.ts +6 -0
- package/src/generators/preset/workspace-files-foundation-workload-roles.js +406 -0
- package/src/generators/preset/workspace-files-foundation-workload-roles.js.map +1 -0
- package/src/generators/preset/workspace-files-performance-cost.d.ts +66 -0
- package/src/generators/preset/workspace-files-performance-cost.js +866 -0
- package/src/generators/preset/workspace-files-performance-cost.js.map +1 -0
- package/src/generators/preset/workspace-files-performance-load.d.ts +73 -0
- package/src/generators/preset/workspace-files-performance-load.js +801 -0
- package/src/generators/preset/workspace-files-performance-load.js.map +1 -0
- package/src/generators/preset/workspace-files-workload-role-baseline.d.ts +9 -0
- package/src/generators/preset/workspace-files-workload-role-baseline.js +1102 -0
- package/src/generators/preset/workspace-files-workload-role-baseline.js.map +1 -0
- package/src/generators/preset/workspace-files.d.ts +14 -1
- package/src/generators/preset/workspace-files.js +8189 -245
- package/src/generators/preset/workspace-files.js.map +1 -1
- package/src/generators/repository-policy/repository-policy.js +3 -47
- package/src/generators/repository-policy/repository-policy.js.map +1 -1
- package/src/generators/slice/built-in-plan-api-contract-files.d.ts +2 -0
- package/src/generators/slice/built-in-plan-api-contract-files.js +2656 -0
- package/src/generators/slice/built-in-plan-api-contract-files.js.map +1 -0
- package/src/generators/slice/built-in-plan-api-files.d.ts +2 -0
- package/src/generators/slice/built-in-plan-api-files.js +1186 -0
- package/src/generators/slice/built-in-plan-api-files.js.map +1 -0
- package/src/generators/slice/built-in-plan-async-work-files.d.ts +2 -0
- package/src/generators/slice/built-in-plan-async-work-files.js +7903 -0
- package/src/generators/slice/built-in-plan-async-work-files.js.map +1 -0
- package/src/generators/slice/built-in-plan-authorization-files.d.ts +2 -0
- package/src/generators/slice/built-in-plan-authorization-files.js +2314 -0
- package/src/generators/slice/built-in-plan-authorization-files.js.map +1 -0
- package/src/generators/slice/built-in-plan-authorization-infrastructure-files.d.ts +2 -0
- package/src/generators/slice/built-in-plan-authorization-infrastructure-files.js +559 -0
- package/src/generators/slice/built-in-plan-authorization-infrastructure-files.js.map +1 -0
- package/src/generators/slice/built-in-plan-data-deletion-files.d.ts +2 -0
- package/src/generators/slice/built-in-plan-data-deletion-files.js +1101 -0
- package/src/generators/slice/built-in-plan-data-deletion-files.js.map +1 -0
- package/src/generators/slice/built-in-plan-data-export-infrastructure-files.d.ts +2 -0
- package/src/generators/slice/built-in-plan-data-export-infrastructure-files.js +249 -0
- package/src/generators/slice/built-in-plan-data-export-infrastructure-files.js.map +1 -0
- package/src/generators/slice/built-in-plan-data-files.d.ts +2 -0
- package/src/generators/slice/built-in-plan-data-files.js +1530 -0
- package/src/generators/slice/built-in-plan-data-files.js.map +1 -0
- package/src/generators/slice/built-in-plan-data-lifecycle-files.d.ts +2 -0
- package/src/generators/slice/built-in-plan-data-lifecycle-files.js +750 -0
- package/src/generators/slice/built-in-plan-data-lifecycle-files.js.map +1 -0
- package/src/generators/slice/built-in-plan-data-lifecycle-runtime-files.d.ts +2 -0
- package/src/generators/slice/built-in-plan-data-lifecycle-runtime-files.js +828 -0
- package/src/generators/slice/built-in-plan-data-lifecycle-runtime-files.js.map +1 -0
- package/src/generators/slice/built-in-plan-data-retention-job-files.d.ts +2 -0
- package/src/generators/slice/built-in-plan-data-retention-job-files.js +2604 -0
- package/src/generators/slice/built-in-plan-data-retention-job-files.js.map +1 -0
- package/src/generators/slice/built-in-plan-data-retention-launcher-files.d.ts +2 -0
- package/src/generators/slice/built-in-plan-data-retention-launcher-files.js +144 -0
- package/src/generators/slice/built-in-plan-data-retention-launcher-files.js.map +1 -0
- package/src/generators/slice/built-in-plan-data-rights-files.d.ts +2 -0
- package/src/generators/slice/built-in-plan-data-rights-files.js +4628 -0
- package/src/generators/slice/built-in-plan-data-rights-files.js.map +1 -0
- package/src/generators/slice/built-in-plan-database-lifecycle-files.d.ts +2 -0
- package/src/generators/slice/built-in-plan-database-lifecycle-files.js +1901 -0
- package/src/generators/slice/built-in-plan-database-lifecycle-files.js.map +1 -0
- package/src/generators/slice/built-in-plan-database-retirement-rehearsal-files.d.ts +2 -0
- package/src/generators/slice/built-in-plan-database-retirement-rehearsal-files.js +485 -0
- package/src/generators/slice/built-in-plan-database-retirement-rehearsal-files.js.map +1 -0
- package/src/generators/slice/built-in-plan-foundation-files.d.ts +2 -0
- package/src/generators/slice/built-in-plan-foundation-files.js +184 -0
- package/src/generators/slice/built-in-plan-foundation-files.js.map +1 -0
- package/src/generators/slice/built-in-plan-frontend-files.d.ts +2 -0
- package/src/generators/slice/built-in-plan-frontend-files.js +2233 -0
- package/src/generators/slice/built-in-plan-frontend-files.js.map +1 -0
- package/src/generators/slice/built-in-plan-infrastructure-files.d.ts +2 -0
- package/src/generators/slice/built-in-plan-infrastructure-files.js +3038 -0
- package/src/generators/slice/built-in-plan-infrastructure-files.js.map +1 -0
- package/src/generators/slice/built-in-plan-infrastructure-safety-files.d.ts +2 -0
- package/src/generators/slice/built-in-plan-infrastructure-safety-files.js +312 -0
- package/src/generators/slice/built-in-plan-infrastructure-safety-files.js.map +1 -0
- package/src/generators/slice/built-in-plan-metadata-files.d.ts +3 -0
- package/src/generators/slice/built-in-plan-metadata-files.js +219 -0
- package/src/generators/slice/built-in-plan-metadata-files.js.map +1 -0
- package/src/generators/slice/built-in-plan-operations-files.d.ts +2 -0
- package/src/generators/slice/built-in-plan-operations-files.js +630 -0
- package/src/generators/slice/built-in-plan-operations-files.js.map +1 -0
- package/src/generators/slice/built-in-plan-performance-files.d.ts +3 -0
- package/src/generators/slice/built-in-plan-performance-files.js +3293 -0
- package/src/generators/slice/built-in-plan-performance-files.js.map +1 -0
- package/src/generators/slice/built-in-plan-project-files.d.ts +3 -0
- package/src/generators/slice/built-in-plan-project-files.js +979 -0
- package/src/generators/slice/built-in-plan-project-files.js.map +1 -0
- package/src/generators/slice/built-in-plan-release-files.d.ts +2 -0
- package/src/generators/slice/built-in-plan-release-files.js +6387 -0
- package/src/generators/slice/built-in-plan-release-files.js.map +1 -0
- package/src/generators/slice/built-in-plan-reliability-files.d.ts +2 -0
- package/src/generators/slice/built-in-plan-reliability-files.js +4499 -0
- package/src/generators/slice/built-in-plan-reliability-files.js.map +1 -0
- package/src/generators/slice/built-in-plan-secret-runtime-files.d.ts +2 -0
- package/src/generators/slice/built-in-plan-secret-runtime-files.js +1004 -0
- package/src/generators/slice/built-in-plan-secret-runtime-files.js.map +1 -0
- package/src/generators/slice/built-in-plan-shared.d.ts +53 -0
- package/src/generators/slice/built-in-plan-shared.js +113 -0
- package/src/generators/slice/built-in-plan-shared.js.map +1 -0
- package/src/generators/slice/built-in-plan-testing-files.d.ts +2 -0
- package/src/generators/slice/built-in-plan-testing-files.js +2356 -0
- package/src/generators/slice/built-in-plan-testing-files.js.map +1 -0
- package/src/generators/slice/built-in-plan-verification-files.d.ts +2 -0
- package/src/generators/slice/built-in-plan-verification-files.js +358 -0
- package/src/generators/slice/built-in-plan-verification-files.js.map +1 -0
- package/src/generators/slice/built-in-plan-web-files.d.ts +2 -0
- package/src/generators/slice/built-in-plan-web-files.js +430 -0
- package/src/generators/slice/built-in-plan-web-files.js.map +1 -0
- package/src/generators/slice/built-in-plan-workload-role-files.d.ts +2 -0
- package/src/generators/slice/built-in-plan-workload-role-files.js +2356 -0
- package/src/generators/slice/built-in-plan-workload-role-files.js.map +1 -0
- package/src/generators/slice/built-in-plan.d.ts +8 -0
- package/src/generators/slice/built-in-plan.js +264 -0
- package/src/generators/slice/built-in-plan.js.map +1 -0
- package/src/generators/slice/schema.json +1 -1
- package/src/generators/slice/slice.d.ts +1 -22
- package/src/generators/slice/slice.js +185 -1510
- package/src/generators/slice/slice.js.map +1 -1
- package/src/generators/upgrade/schema.d.ts +2 -0
- package/src/generators/upgrade/schema.json +11 -1
- package/src/generators/upgrade/upgrade.d.ts +51 -1
- package/src/generators/upgrade/upgrade.js +820 -82
- package/src/generators/upgrade/upgrade.js.map +1 -1
- package/src/generators/workload/frozen-container.d.ts +8 -0
- package/src/generators/workload/frozen-container.js +36 -0
- package/src/generators/workload/frozen-container.js.map +1 -0
- package/src/generators/workload/performance-registration.d.ts +32 -0
- package/src/generators/workload/performance-registration.js +226 -0
- package/src/generators/workload/performance-registration.js.map +1 -0
- package/src/generators/workload/reliability-registration.d.ts +16 -0
- package/src/generators/workload/reliability-registration.js +150 -0
- package/src/generators/workload/reliability-registration.js.map +1 -0
- package/src/generators/workload/schema.d.ts +1 -0
- package/src/generators/workload/schema.json +6 -1
- package/src/generators/workload/service-performance-files.d.ts +4 -0
- package/src/generators/workload/service-performance-files.js +1846 -0
- package/src/generators/workload/service-performance-files.js.map +1 -0
- package/src/generators/workload/workload.d.ts +3 -2
- package/src/generators/workload/workload.js +1083 -18
- package/src/generators/workload/workload.js.map +1 -1
- package/src/git-policy/action-pins.d.ts +10 -0
- package/src/git-policy/action-pins.js +10 -0
- package/src/git-policy/action-pins.js.map +1 -1
- package/src/git-policy/github-bootstrap.d.ts +14 -1
- package/src/git-policy/github-bootstrap.js +274 -46
- package/src/git-policy/github-bootstrap.js.map +1 -1
- package/src/index.d.ts +2 -0
- package/src/index.js +2 -0
- package/src/index.js.map +1 -1
- package/src/infrastructure-change/convergence.d.ts +13 -0
- package/src/infrastructure-change/convergence.js +85 -0
- package/src/infrastructure-change/convergence.js.map +1 -0
- package/src/infrastructure-change/drift.d.ts +13 -0
- package/src/infrastructure-change/drift.js +112 -0
- package/src/infrastructure-change/drift.js.map +1 -0
- package/src/infrastructure-change/index.d.ts +7 -0
- package/src/infrastructure-change/index.js +11 -0
- package/src/infrastructure-change/index.js.map +1 -0
- package/src/infrastructure-change/model.d.ts +253 -0
- package/src/infrastructure-change/model.js +28 -0
- package/src/infrastructure-change/model.js.map +1 -0
- package/src/infrastructure-change/normalize.d.ts +12 -0
- package/src/infrastructure-change/normalize.js +245 -0
- package/src/infrastructure-change/normalize.js.map +1 -0
- package/src/infrastructure-change/plan.d.ts +40 -0
- package/src/infrastructure-change/plan.js +361 -0
- package/src/infrastructure-change/plan.js.map +1 -0
- package/src/infrastructure-change/risk.d.ts +7 -0
- package/src/infrastructure-change/risk.js +269 -0
- package/src/infrastructure-change/risk.js.map +1 -0
- package/src/infrastructure-change/summary.d.ts +20 -0
- package/src/infrastructure-change/summary.js +130 -0
- package/src/infrastructure-change/summary.js.map +1 -0
- package/src/local-dev/context-discovery.d.ts +2 -0
- package/src/local-dev/context-discovery.js +59 -0
- package/src/local-dev/context-discovery.js.map +1 -0
- package/src/local-dev/contract-validation.d.ts +15 -0
- package/src/local-dev/contract-validation.js +84 -0
- package/src/local-dev/contract-validation.js.map +1 -0
- package/src/local-dev/contracts.d.ts +10 -1
- package/src/local-dev/contracts.js +57 -5
- package/src/local-dev/contracts.js.map +1 -1
- package/src/local-dev/control-contract.d.ts +67 -0
- package/src/local-dev/control-contract.js +180 -0
- package/src/local-dev/control-contract.js.map +1 -0
- package/src/local-dev/dev-command-core.d.ts +60 -0
- package/src/local-dev/dev-command-core.js +89 -0
- package/src/local-dev/dev-command-core.js.map +1 -0
- package/src/local-dev/effects.d.ts +16 -0
- package/src/local-dev/effects.js +63 -0
- package/src/local-dev/effects.js.map +1 -0
- package/src/local-dev/environment-inspector.d.ts +125 -0
- package/src/local-dev/environment-inspector.js +687 -0
- package/src/local-dev/environment-inspector.js.map +1 -0
- package/src/local-dev/environment-state.d.ts +113 -0
- package/src/local-dev/environment-state.js +463 -0
- package/src/local-dev/environment-state.js.map +1 -0
- package/src/local-dev/errors.d.ts +6 -0
- package/src/local-dev/errors.js +41 -0
- package/src/local-dev/errors.js.map +1 -0
- package/src/local-dev/fleet-client.d.ts +33 -0
- package/src/local-dev/fleet-client.js +156 -0
- package/src/local-dev/fleet-client.js.map +1 -0
- package/src/local-dev/fleet-configuration.d.ts +13 -0
- package/src/local-dev/fleet-configuration.js +71 -0
- package/src/local-dev/fleet-configuration.js.map +1 -0
- package/src/local-dev/fleet-inventory.d.ts +56 -0
- package/src/local-dev/fleet-inventory.js +469 -0
- package/src/local-dev/fleet-inventory.js.map +1 -0
- package/src/local-dev/fleet-ipc.d.ts +28 -0
- package/src/local-dev/fleet-ipc.js +267 -0
- package/src/local-dev/fleet-ipc.js.map +1 -0
- package/src/local-dev/fleet-launcher.d.ts +53 -0
- package/src/local-dev/fleet-launcher.js +379 -0
- package/src/local-dev/fleet-launcher.js.map +1 -0
- package/src/local-dev/fleet-lock.d.ts +41 -0
- package/src/local-dev/fleet-lock.js +135 -0
- package/src/local-dev/fleet-lock.js.map +1 -0
- package/src/local-dev/fleet-logs.d.ts +66 -0
- package/src/local-dev/fleet-logs.js +542 -0
- package/src/local-dev/fleet-logs.js.map +1 -0
- package/src/local-dev/fleet-process.d.ts +50 -0
- package/src/local-dev/fleet-process.js +289 -0
- package/src/local-dev/fleet-process.js.map +1 -0
- package/src/local-dev/fleet-runtime.d.ts +22 -0
- package/src/local-dev/fleet-runtime.js +368 -0
- package/src/local-dev/fleet-runtime.js.map +1 -0
- package/src/local-dev/fleet-session.d.ts +70 -0
- package/src/local-dev/fleet-session.js +301 -0
- package/src/local-dev/fleet-session.js.map +1 -0
- package/src/local-dev/fleet-slice-runner-daemon.d.ts +1 -0
- package/src/local-dev/fleet-slice-runner-daemon.js +21 -0
- package/src/local-dev/fleet-slice-runner-daemon.js.map +1 -0
- package/src/local-dev/fleet-startup.d.ts +22 -0
- package/src/local-dev/fleet-startup.js +114 -0
- package/src/local-dev/fleet-startup.js.map +1 -0
- package/src/local-dev/fleet-state.d.ts +62 -0
- package/src/local-dev/fleet-state.js +297 -0
- package/src/local-dev/fleet-state.js.map +1 -0
- package/src/local-dev/fleet-supervisor-daemon.d.ts +1 -0
- package/src/local-dev/fleet-supervisor-daemon.js +41 -0
- package/src/local-dev/fleet-supervisor-daemon.js.map +1 -0
- package/src/local-dev/fleet-supervisor.d.ts +52 -0
- package/src/local-dev/fleet-supervisor.js +384 -0
- package/src/local-dev/fleet-supervisor.js.map +1 -0
- package/src/local-dev/gateway.js +1 -1
- package/src/local-dev/gateway.js.map +1 -1
- package/src/local-dev/identity.js +4 -6
- package/src/local-dev/identity.js.map +1 -1
- package/src/local-dev/index.d.ts +1 -0
- package/src/local-dev/index.js +1 -0
- package/src/local-dev/index.js.map +1 -1
- package/src/local-dev/local-context.d.ts +62 -0
- package/src/local-dev/local-context.js +299 -0
- package/src/local-dev/local-context.js.map +1 -0
- package/src/local-dev/nx-workspace.d.ts +45 -0
- package/src/local-dev/nx-workspace.js +228 -0
- package/src/local-dev/nx-workspace.js.map +1 -0
- package/src/local-dev/personal-secret-ledger.d.ts +75 -0
- package/src/local-dev/personal-secret-ledger.js +391 -0
- package/src/local-dev/personal-secret-ledger.js.map +1 -0
- package/src/local-dev/personal-stage.d.ts +18 -0
- package/src/local-dev/personal-stage.js +129 -0
- package/src/local-dev/personal-stage.js.map +1 -0
- package/src/local-dev/private-state.d.ts +17 -0
- package/src/local-dev/private-state.js +254 -0
- package/src/local-dev/private-state.js.map +1 -0
- package/src/local-dev/process-identity.d.ts +9 -0
- package/src/local-dev/process-identity.js +79 -0
- package/src/local-dev/process-identity.js.map +1 -0
- package/src/local-dev/runtime-paths.d.ts +17 -0
- package/src/local-dev/runtime-paths.js +33 -0
- package/src/local-dev/runtime-paths.js.map +1 -0
- package/src/local-dev/secret-slice-claim.d.ts +17 -0
- package/src/local-dev/secret-slice-claim.js +75 -0
- package/src/local-dev/secret-slice-claim.js.map +1 -0
- package/src/local-dev/slice-claim.d.ts +36 -0
- package/src/local-dev/slice-claim.js +199 -0
- package/src/local-dev/slice-claim.js.map +1 -0
- package/src/local-dev/slice-runner.d.ts +121 -0
- package/src/local-dev/slice-runner.js +715 -0
- package/src/local-dev/slice-runner.js.map +1 -0
- package/src/local-dev/tls.js +51 -17
- package/src/local-dev/tls.js.map +1 -1
- package/src/ownership/composition.d.ts +5 -2
- package/src/ownership/composition.js +26 -3
- package/src/ownership/composition.js.map +1 -1
- package/src/ownership/hash.d.ts +1 -1
- package/src/ownership/hash.js +3 -1
- package/src/ownership/hash.js.map +1 -1
- package/src/ownership/record.d.ts +32 -1
- package/src/ownership/record.js +139 -2
- package/src/ownership/record.js.map +1 -1
- package/src/performance/cost-collection.d.ts +225 -0
- package/src/performance/cost-collection.js +825 -0
- package/src/performance/cost-collection.js.map +1 -0
- package/src/performance/cost-controls.d.ts +131 -0
- package/src/performance/cost-controls.js +336 -0
- package/src/performance/cost-controls.js.map +1 -0
- package/src/performance/evidence.d.ts +140 -0
- package/src/performance/evidence.js +599 -0
- package/src/performance/evidence.js.map +1 -0
- package/src/performance/index.d.ts +10 -0
- package/src/performance/index.js +14 -0
- package/src/performance/index.js.map +1 -0
- package/src/performance/load-safety.d.ts +84 -0
- package/src/performance/load-safety.js +346 -0
- package/src/performance/load-safety.js.map +1 -0
- package/src/performance/model.d.ts +129 -0
- package/src/performance/model.js +452 -0
- package/src/performance/model.js.map +1 -0
- package/src/performance/pr-artifact-budget.d.ts +42 -0
- package/src/performance/pr-artifact-budget.js +151 -0
- package/src/performance/pr-artifact-budget.js.map +1 -0
- package/src/performance/runner.d.ts +75 -0
- package/src/performance/runner.js +227 -0
- package/src/performance/runner.js.map +1 -0
- package/src/performance/scenario-endpoint.d.ts +1 -0
- package/src/performance/scenario-endpoint.js +5 -0
- package/src/performance/scenario-endpoint.js.map +1 -0
- package/src/performance/scenario.d.ts +193 -0
- package/src/performance/scenario.js +736 -0
- package/src/performance/scenario.js.map +1 -0
- package/src/performance/test-fixtures.d.ts +7 -0
- package/src/performance/test-fixtures.js +169 -0
- package/src/performance/test-fixtures.js.map +1 -0
- package/src/performance/unit-cost.d.ts +105 -0
- package/src/performance/unit-cost.js +364 -0
- package/src/performance/unit-cost.js.map +1 -0
- package/src/release/index.d.ts +4 -0
- package/src/release/index.js +8 -0
- package/src/release/index.js.map +1 -0
- package/src/release/lifecycle.d.ts +132 -0
- package/src/release/lifecycle.js +411 -0
- package/src/release/lifecycle.js.map +1 -0
- package/src/release/manifest.d.ts +148 -0
- package/src/release/manifest.js +503 -0
- package/src/release/manifest.js.map +1 -0
- package/src/release/rollout.d.ts +170 -0
- package/src/release/rollout.js +578 -0
- package/src/release/rollout.js.map +1 -0
- package/src/release/source-authority.d.ts +19 -0
- package/src/release/source-authority.js +195 -0
- package/src/release/source-authority.js.map +1 -0
- package/src/repository-policy/deployment-plan.d.ts +2 -1
- package/src/repository-policy/deployment-plan.js +2 -0
- package/src/repository-policy/deployment-plan.js.map +1 -1
- package/src/repository-policy/policy.d.ts +1 -1
- package/src/repository-policy/policy.js +29 -25
- package/src/repository-policy/policy.js.map +1 -1
- package/src/schemas/aggregate-count-evidence-v1.json +135 -0
- package/src/schemas/api-contract-baseline-v1.json +674 -0
- package/src/schemas/api-contract-compatibility-report-v1.json +126 -0
- package/src/schemas/aws-billing-actual-evidence-v1.json +407 -0
- package/src/schemas/blueprint-v1.json +18 -1
- package/src/schemas/data-lifecycle-policy-v1.json +517 -0
- package/src/schemas/generated-ownership-v2.json +22 -0
- package/src/schemas/performance-policy-v1.json +335 -0
- package/src/schemas/repository-policy-v1.json +1 -0
- package/src/schemas/testing-evidence-quarantine-v1.json +168 -0
- package/src/schemas/testing-evidence-result-v1.json +319 -0
- package/src/secrets/adapters.d.ts +49 -0
- package/src/secrets/adapters.js +184 -0
- package/src/secrets/adapters.js.map +1 -0
- package/src/secrets/aws-control-plane.d.ts +20 -0
- package/src/secrets/aws-control-plane.js +255 -0
- package/src/secrets/aws-control-plane.js.map +1 -0
- package/src/secrets/declaration-verification.d.ts +11 -0
- package/src/secrets/declaration-verification.js +68 -0
- package/src/secrets/declaration-verification.js.map +1 -0
- package/src/secrets/guardrails.d.ts +26 -0
- package/src/secrets/guardrails.js +328 -0
- package/src/secrets/guardrails.js.map +1 -0
- package/src/secrets/index.d.ts +12 -0
- package/src/secrets/index.js +16 -0
- package/src/secrets/index.js.map +1 -0
- package/src/secrets/metadata.d.ts +48 -0
- package/src/secrets/metadata.js +171 -0
- package/src/secrets/metadata.js.map +1 -0
- package/src/secrets/model.d.ts +49 -0
- package/src/secrets/model.js +125 -0
- package/src/secrets/model.js.map +1 -0
- package/src/secrets/operator.d.ts +101 -0
- package/src/secrets/operator.js +229 -0
- package/src/secrets/operator.js.map +1 -0
- package/src/secrets/permissions.d.ts +34 -0
- package/src/secrets/permissions.js +86 -0
- package/src/secrets/permissions.js.map +1 -0
- package/src/secrets/personal-command.d.ts +83 -0
- package/src/secrets/personal-command.js +197 -0
- package/src/secrets/personal-command.js.map +1 -0
- package/src/secrets/personal-ledger.d.ts +21 -0
- package/src/secrets/personal-ledger.js +135 -0
- package/src/secrets/personal-ledger.js.map +1 -0
- package/src/secrets/registry.d.ts +9 -0
- package/src/secrets/registry.js +86 -0
- package/src/secrets/registry.js.map +1 -0
- package/src/secrets/verification.d.ts +18 -0
- package/src/secrets/verification.js +139 -0
- package/src/secrets/verification.js.map +1 -0
- package/src/secrets/workload-access.d.ts +46 -0
- package/src/secrets/workload-access.js +163 -0
- package/src/secrets/workload-access.js.map +1 -0
- package/src/supply-chain/action-pins.d.ts +26 -0
- package/src/supply-chain/action-pins.js +208 -0
- package/src/supply-chain/action-pins.js.map +1 -0
- package/src/supply-chain/blueprint-trust-git.d.ts +130 -0
- package/src/supply-chain/blueprint-trust-git.js +1581 -0
- package/src/supply-chain/blueprint-trust-git.js.map +1 -0
- package/src/supply-chain/blueprint-trust-process.d.ts +4 -0
- package/src/supply-chain/blueprint-trust-process.js +88 -0
- package/src/supply-chain/blueprint-trust-process.js.map +1 -0
- package/src/supply-chain/blueprint-trust.d.ts +354 -0
- package/src/supply-chain/blueprint-trust.js +1558 -0
- package/src/supply-chain/blueprint-trust.js.map +1 -0
- package/src/supply-chain/blueprint-verification-authority.d.ts +62 -0
- package/src/supply-chain/blueprint-verification-authority.js +588 -0
- package/src/supply-chain/blueprint-verification-authority.js.map +1 -0
- package/src/supply-chain/blueprint-verification-execution.d.ts +68 -0
- package/src/supply-chain/blueprint-verification-execution.js +624 -0
- package/src/supply-chain/blueprint-verification-execution.js.map +1 -0
- package/src/supply-chain/build-output.d.ts +35 -0
- package/src/supply-chain/build-output.js +398 -0
- package/src/supply-chain/build-output.js.map +1 -0
- package/src/supply-chain/common.d.ts +21 -0
- package/src/supply-chain/common.js +107 -0
- package/src/supply-chain/common.js.map +1 -0
- package/src/supply-chain/container-artifact-contract.d.ts +23 -0
- package/src/supply-chain/container-artifact-contract.js +208 -0
- package/src/supply-chain/container-artifact-contract.js.map +1 -0
- package/src/supply-chain/container-release.d.ts +119 -0
- package/src/supply-chain/container-release.js +496 -0
- package/src/supply-chain/container-release.js.map +1 -0
- package/src/supply-chain/ecr-applicability.d.ts +19 -0
- package/src/supply-chain/ecr-applicability.js +103 -0
- package/src/supply-chain/ecr-applicability.js.map +1 -0
- package/src/supply-chain/ecr-scanning.d.ts +83 -0
- package/src/supply-chain/ecr-scanning.js +321 -0
- package/src/supply-chain/ecr-scanning.js.map +1 -0
- package/src/supply-chain/ecs-deployed-image.d.ts +85 -0
- package/src/supply-chain/ecs-deployed-image.js +300 -0
- package/src/supply-chain/ecs-deployed-image.js.map +1 -0
- package/src/supply-chain/errors.d.ts +6 -0
- package/src/supply-chain/errors.js +15 -0
- package/src/supply-chain/errors.js.map +1 -0
- package/src/supply-chain/generated-blueprint-trust-command.d.ts +4 -0
- package/src/supply-chain/generated-blueprint-trust-command.js +1362 -0
- package/src/supply-chain/generated-blueprint-trust-command.js.map +1 -0
- package/src/supply-chain/generated-command.d.ts +1 -0
- package/src/supply-chain/generated-command.js +687 -0
- package/src/supply-chain/generated-command.js.map +1 -0
- package/src/supply-chain/generated-container-release-command.d.ts +16 -0
- package/src/supply-chain/generated-container-release-command.js +2087 -0
- package/src/supply-chain/generated-container-release-command.js.map +1 -0
- package/src/supply-chain/generated-ecr-collector-command.d.ts +21 -0
- package/src/supply-chain/generated-ecr-collector-command.js +1338 -0
- package/src/supply-chain/generated-ecr-collector-command.js.map +1 -0
- package/src/supply-chain/generated-files.d.ts +13 -0
- package/src/supply-chain/generated-files.js +401 -0
- package/src/supply-chain/generated-files.js.map +1 -0
- package/src/supply-chain/generated-scanner-command.d.ts +1 -0
- package/src/supply-chain/generated-scanner-command.js +718 -0
- package/src/supply-chain/generated-scanner-command.js.map +1 -0
- package/src/supply-chain/index.d.ts +28 -0
- package/src/supply-chain/index.js +35 -0
- package/src/supply-chain/index.js.map +1 -0
- package/src/supply-chain/inventory.d.ts +79 -0
- package/src/supply-chain/inventory.js +717 -0
- package/src/supply-chain/inventory.js.map +1 -0
- package/src/supply-chain/license-expression.d.ts +7 -0
- package/src/supply-chain/license-expression.js +128 -0
- package/src/supply-chain/license-expression.js.map +1 -0
- package/src/supply-chain/pnpm-toolchain-authority.d.ts +31 -0
- package/src/supply-chain/pnpm-toolchain-authority.js +316 -0
- package/src/supply-chain/pnpm-toolchain-authority.js.map +1 -0
- package/src/supply-chain/policy.d.ts +105 -0
- package/src/supply-chain/policy.js +301 -0
- package/src/supply-chain/policy.js.map +1 -0
- package/src/supply-chain/publication.d.ts +48 -0
- package/src/supply-chain/publication.js +85 -0
- package/src/supply-chain/publication.js.map +1 -0
- package/src/supply-chain/sbom.d.ts +228 -0
- package/src/supply-chain/sbom.js +826 -0
- package/src/supply-chain/sbom.js.map +1 -0
- package/src/supply-chain/scanner.d.ts +77 -0
- package/src/supply-chain/scanner.js +502 -0
- package/src/supply-chain/scanner.js.map +1 -0
- package/src/supply-chain/vendored-source.d.ts +95 -0
- package/src/supply-chain/vendored-source.js +315 -0
- package/src/supply-chain/vendored-source.js.map +1 -0
- package/src/testing-evidence/fixture-scope.d.ts +52 -0
- package/src/testing-evidence/fixture-scope.js +140 -0
- package/src/testing-evidence/fixture-scope.js.map +1 -0
- package/src/testing-evidence/index.d.ts +5 -0
- package/src/testing-evidence/index.js +9 -0
- package/src/testing-evidence/index.js.map +1 -0
- package/src/testing-evidence/quarantine.d.ts +48 -0
- package/src/testing-evidence/quarantine.js +253 -0
- package/src/testing-evidence/quarantine.js.map +1 -0
- package/src/testing-evidence/result.d.ts +108 -0
- package/src/testing-evidence/result.js +571 -0
- package/src/testing-evidence/result.js.map +1 -0
- package/src/testing-evidence/runner-adapter.d.ts +51 -0
- package/src/testing-evidence/runner-adapter.js +288 -0
- package/src/testing-evidence/runner-adapter.js.map +1 -0
- package/src/testing-evidence/sensitive-artifacts.d.ts +13 -0
- package/src/testing-evidence/sensitive-artifacts.js +93 -0
- package/src/testing-evidence/sensitive-artifacts.js.map +1 -0
- package/src/work-markers/work-markers.js +4 -3
- package/src/work-markers/work-markers.js.map +1 -1
- package/src/workload-roles/account-baseline.d.ts +132 -0
- package/src/workload-roles/account-baseline.js +743 -0
- package/src/workload-roles/account-baseline.js.map +1 -0
- package/src/workload-roles/authority.d.ts +38 -0
- package/src/workload-roles/authority.js +156 -0
- package/src/workload-roles/authority.js.map +1 -0
- package/src/workload-roles/aws-adapter.d.ts +99 -0
- package/src/workload-roles/aws-adapter.js +416 -0
- package/src/workload-roles/aws-adapter.js.map +1 -0
- package/src/workload-roles/aws-internal-handler.d.ts +27 -0
- package/src/workload-roles/aws-internal-handler.js +58 -0
- package/src/workload-roles/aws-internal-handler.js.map +1 -0
- package/src/workload-roles/aws-s3-state.d.ts +52 -0
- package/src/workload-roles/aws-s3-state.js +164 -0
- package/src/workload-roles/aws-s3-state.js.map +1 -0
- package/src/workload-roles/aws-sdk-control-plane.d.ts +12 -0
- package/src/workload-roles/aws-sdk-control-plane.js +291 -0
- package/src/workload-roles/aws-sdk-control-plane.js.map +1 -0
- package/src/workload-roles/boundary.d.ts +33 -0
- package/src/workload-roles/boundary.js +130 -0
- package/src/workload-roles/boundary.js.map +1 -0
- package/src/workload-roles/convergence.d.ts +66 -0
- package/src/workload-roles/convergence.js +186 -0
- package/src/workload-roles/convergence.js.map +1 -0
- package/src/workload-roles/effective-policy.d.ts +51 -0
- package/src/workload-roles/effective-policy.js +183 -0
- package/src/workload-roles/effective-policy.js.map +1 -0
- package/src/workload-roles/handler.d.ts +43 -0
- package/src/workload-roles/handler.js +130 -0
- package/src/workload-roles/handler.js.map +1 -0
- package/src/workload-roles/history.d.ts +47 -0
- package/src/workload-roles/history.js +324 -0
- package/src/workload-roles/history.js.map +1 -0
- package/src/workload-roles/index.d.ts +17 -0
- package/src/workload-roles/index.js +21 -0
- package/src/workload-roles/index.js.map +1 -0
- package/src/workload-roles/injection.d.ts +119 -0
- package/src/workload-roles/injection.js +495 -0
- package/src/workload-roles/injection.js.map +1 -0
- package/src/workload-roles/manifest.d.ts +54 -0
- package/src/workload-roles/manifest.js +343 -0
- package/src/workload-roles/manifest.js.map +1 -0
- package/src/workload-roles/provisioner.d.ts +70 -0
- package/src/workload-roles/provisioner.js +231 -0
- package/src/workload-roles/provisioner.js.map +1 -0
- package/src/workload-roles/registration.d.ts +101 -0
- package/src/workload-roles/registration.js +457 -0
- package/src/workload-roles/registration.js.map +1 -0
- package/src/workload-roles/stage-boundary.d.ts +31 -0
- package/src/workload-roles/stage-boundary.js +208 -0
- package/src/workload-roles/stage-boundary.js.map +1 -0
- package/src/workload-roles/standard-slice.d.ts +54 -0
- package/src/workload-roles/standard-slice.js +252 -0
- package/src/workload-roles/standard-slice.js.map +1 -0
- package/migrations/standard-pre-v1-to-v1.md +0 -11
- package/src/foundation/aurora-logical-database.d.ts +0 -76
- package/src/foundation/aurora-logical-database.js +0 -419
- package/src/foundation/aurora-logical-database.js.map +0 -1
|
@@ -0,0 +1,4628 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.renderProductDataRightsFiles = renderProductDataRightsFiles;
|
|
4
|
+
const built_in_plan_shared_js_1 = require("./built-in-plan-shared.js");
|
|
5
|
+
const PENDING_EXPORT_CLEANUP_REPORT_BOUND = 1_000;
|
|
6
|
+
const PENDING_EXPORT_CLEANUP_REPORT_BOUND_LITERAL = String(PENDING_EXPORT_CLEANUP_REPORT_BOUND).replace(/\B(?=(?:\d{3})+$)/gu, '_');
|
|
7
|
+
/** @internal */
|
|
8
|
+
function renderProductDataRightsFiles(name, pascalName) {
|
|
9
|
+
const identifierPrefix = (0, built_in_plan_shared_js_1.lowerCamelIdentifier)(pascalName);
|
|
10
|
+
return {
|
|
11
|
+
'data/data-rights-shared.ts': `import type { AuthorizationPrincipal } from '@empire-builder-kit/runtime/authorization';
|
|
12
|
+
import {
|
|
13
|
+
createDataLifecycleAuditRecord,
|
|
14
|
+
createDataLifecyclePseudonymousReference,
|
|
15
|
+
evaluateDataLifecycleHolds,
|
|
16
|
+
type DataLifecycleAuditAction,
|
|
17
|
+
type DataLifecycleEvidenceOutcome,
|
|
18
|
+
type DataLifecycleRuntimeHoldV1,
|
|
19
|
+
} from '@empire-builder-kit/runtime/data-lifecycle';
|
|
20
|
+
import type { Client as PgClient } from 'pg';
|
|
21
|
+
import {
|
|
22
|
+
approvalReference,
|
|
23
|
+
productRecordReference,
|
|
24
|
+
} from './pseudonymous-reference.js';
|
|
25
|
+
import {
|
|
26
|
+
${pascalName}DataLifecycleRuntimePolicy,
|
|
27
|
+
} from './runtime-policy.cjs';
|
|
28
|
+
|
|
29
|
+
export const DATA_RIGHTS_POLICY_VERSION =
|
|
30
|
+
${pascalName}DataLifecycleRuntimePolicy.policyVersion;
|
|
31
|
+
export const DATA_RIGHTS_AUDIT_RETENTION_DAYS = 400;
|
|
32
|
+
|
|
33
|
+
export function canonicalDataRightsNow(now?: string): string {
|
|
34
|
+
const value = now ?? new Date().toISOString();
|
|
35
|
+
if (new Date(value).toISOString() !== value) {
|
|
36
|
+
throw new Error('Data-rights time must be a canonical ISO-8601 timestamp.');
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function plusDataRightsDays(value: string, days: number): string {
|
|
42
|
+
const result = new Date(value);
|
|
43
|
+
result.setUTCDate(result.getUTCDate() + days);
|
|
44
|
+
return result.toISOString();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function plusDataRightsHours(value: string, hours: number): string {
|
|
48
|
+
return new Date(Date.parse(value) + hours * 60 * 60 * 1_000).toISOString();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function dataRightsSubjectReference(subjectId: string): string {
|
|
52
|
+
return createDataLifecyclePseudonymousReference({
|
|
53
|
+
categoryId: 'authorization-subject',
|
|
54
|
+
slice: '${name}',
|
|
55
|
+
value: subjectId,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function dataRightsCorrelationReference(correlationId: string): string {
|
|
60
|
+
return createDataLifecyclePseudonymousReference({
|
|
61
|
+
categoryId: 'correlation',
|
|
62
|
+
slice: '${name}',
|
|
63
|
+
value: correlationId,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function dataRightsArtifactReference(value: string): string {
|
|
68
|
+
return createDataLifecyclePseudonymousReference({
|
|
69
|
+
categoryId: 'subject-export',
|
|
70
|
+
slice: '${name}',
|
|
71
|
+
value,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function requireDirectHuman(
|
|
76
|
+
principal: AuthorizationPrincipal,
|
|
77
|
+
): AuthorizationPrincipal {
|
|
78
|
+
if (
|
|
79
|
+
principal.actor.type !== 'user' ||
|
|
80
|
+
principal.subject.type !== 'user' ||
|
|
81
|
+
principal.authority.mode !== 'direct'
|
|
82
|
+
) {
|
|
83
|
+
throw new Error('Data-rights request requires a direct human principal.');
|
|
84
|
+
}
|
|
85
|
+
return principal;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function requireDirectAdmin(
|
|
89
|
+
principal: AuthorizationPrincipal,
|
|
90
|
+
): AuthorizationPrincipal {
|
|
91
|
+
requireDirectHuman(principal);
|
|
92
|
+
if (principal.kind !== 'admin') {
|
|
93
|
+
throw new Error('Data-rights administration requires a direct admin.');
|
|
94
|
+
}
|
|
95
|
+
return principal;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function requireExportCleanupAuthority(
|
|
99
|
+
principal: AuthorizationPrincipal,
|
|
100
|
+
): AuthorizationPrincipal {
|
|
101
|
+
if (
|
|
102
|
+
principal.kind === 'agent' &&
|
|
103
|
+
principal.actor.type === 'service' &&
|
|
104
|
+
principal.actor.id === '${name}:data-export-cleanup' &&
|
|
105
|
+
principal.audience === '${name}:data-export-cleanup' &&
|
|
106
|
+
principal.subject.type === 'service' &&
|
|
107
|
+
principal.subject.id === '${name}:data-export-cleanup' &&
|
|
108
|
+
principal.authority.mode === 'machine' &&
|
|
109
|
+
principal.authority.machineIdentityId ===
|
|
110
|
+
'${name}:data-export-cleanup' &&
|
|
111
|
+
principal.scopes.length === 0
|
|
112
|
+
) {
|
|
113
|
+
return principal;
|
|
114
|
+
}
|
|
115
|
+
return requireDirectAdmin(principal);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function effectiveRecordOwner(
|
|
119
|
+
principal: AuthorizationPrincipal,
|
|
120
|
+
): string {
|
|
121
|
+
if (principal.authority.mode === 'machine') {
|
|
122
|
+
throw new Error('Machine authority cannot mutate a subject-owned record.');
|
|
123
|
+
}
|
|
124
|
+
return principal.authority.mode === 'delegated'
|
|
125
|
+
? principal.authority.delegatedBy.id
|
|
126
|
+
: principal.actor.id;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function loadActiveRecordHolds(
|
|
130
|
+
client: PgClient,
|
|
131
|
+
recordId: string,
|
|
132
|
+
now: string,
|
|
133
|
+
operation: 'deletion' | 'export',
|
|
134
|
+
): Promise<readonly DataLifecycleRuntimeHoldV1[]> {
|
|
135
|
+
const scopeRef = productRecordReference(recordId);
|
|
136
|
+
const result = await client.query<{
|
|
137
|
+
holdJson: DataLifecycleRuntimeHoldV1;
|
|
138
|
+
}>(
|
|
139
|
+
\`SELECT hold_json AS "holdJson"
|
|
140
|
+
FROM data_lifecycle_holds
|
|
141
|
+
WHERE category_id = 'product-record'
|
|
142
|
+
AND scope_ref = $1
|
|
143
|
+
AND status = 'active'
|
|
144
|
+
ORDER BY hold_id
|
|
145
|
+
FOR UPDATE\`,
|
|
146
|
+
[scopeRef],
|
|
147
|
+
);
|
|
148
|
+
const holds = [
|
|
149
|
+
...${pascalName}DataLifecycleRuntimePolicy.declaredHolds,
|
|
150
|
+
...result.rows.map(({ holdJson }) => holdJson),
|
|
151
|
+
];
|
|
152
|
+
evaluateDataLifecycleHolds({
|
|
153
|
+
categoryId: 'product-record',
|
|
154
|
+
holds,
|
|
155
|
+
now,
|
|
156
|
+
operation,
|
|
157
|
+
scopeRef,
|
|
158
|
+
});
|
|
159
|
+
return holds;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function writeDataRightsAudit(
|
|
163
|
+
client: PgClient,
|
|
164
|
+
input: {
|
|
165
|
+
action: DataLifecycleAuditAction;
|
|
166
|
+
approvalRequestId?: string;
|
|
167
|
+
correlationId: string;
|
|
168
|
+
now: string;
|
|
169
|
+
outcome: DataLifecycleEvidenceOutcome;
|
|
170
|
+
principal: AuthorizationPrincipal;
|
|
171
|
+
purposeCode: string;
|
|
172
|
+
recordId?: string;
|
|
173
|
+
resourceReference?: string;
|
|
174
|
+
summary: string;
|
|
175
|
+
},
|
|
176
|
+
): Promise<void> {
|
|
177
|
+
const resourceReference =
|
|
178
|
+
input.resourceReference ??
|
|
179
|
+
(input.recordId ? productRecordReference(input.recordId) : undefined);
|
|
180
|
+
if (!resourceReference) {
|
|
181
|
+
throw new Error('Data-rights audit requires a resource reference.');
|
|
182
|
+
}
|
|
183
|
+
const audit = createDataLifecycleAuditRecord({
|
|
184
|
+
action: input.action,
|
|
185
|
+
actorRef: dataRightsSubjectReference(input.principal.actor.id),
|
|
186
|
+
authorityCode: input.principal.authority.mode,
|
|
187
|
+
correlationRef: dataRightsCorrelationReference(input.correlationId),
|
|
188
|
+
outcome: input.outcome,
|
|
189
|
+
policyVersion: DATA_RIGHTS_POLICY_VERSION,
|
|
190
|
+
purposeCode: input.purposeCode,
|
|
191
|
+
summary: input.summary,
|
|
192
|
+
targetCategoryId: 'product-record',
|
|
193
|
+
timestamp: input.now,
|
|
194
|
+
});
|
|
195
|
+
await client.query(
|
|
196
|
+
\`INSERT INTO data_lifecycle_audit
|
|
197
|
+
(action, actor_ref, approval_reference, audit_json, authority_code,
|
|
198
|
+
correlation_ref, expires_at, outcome, policy_version, principal_kind,
|
|
199
|
+
purpose_code, resource_reference, slice, stage, subject_type,
|
|
200
|
+
target_category_id, occurred_at)
|
|
201
|
+
VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7, $8, $9, $10, $11, $12,
|
|
202
|
+
$13, $14, $15, $16, $17)\`,
|
|
203
|
+
[
|
|
204
|
+
audit.action,
|
|
205
|
+
audit.actorRef,
|
|
206
|
+
input.approvalRequestId
|
|
207
|
+
? approvalReference(input.approvalRequestId)
|
|
208
|
+
: null,
|
|
209
|
+
JSON.stringify(audit),
|
|
210
|
+
audit.authorityCode,
|
|
211
|
+
audit.correlationRef,
|
|
212
|
+
plusDataRightsDays(input.now, DATA_RIGHTS_AUDIT_RETENTION_DAYS),
|
|
213
|
+
audit.outcome,
|
|
214
|
+
audit.policyVersion,
|
|
215
|
+
input.principal.kind,
|
|
216
|
+
audit.purposeCode,
|
|
217
|
+
resourceReference,
|
|
218
|
+
'${name}',
|
|
219
|
+
process.env.EBK_STAGE ?? 'development',
|
|
220
|
+
input.principal.actor.type,
|
|
221
|
+
audit.targetCategoryId,
|
|
222
|
+
audit.timestamp,
|
|
223
|
+
],
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
`,
|
|
227
|
+
'data/record-holds.ts': `import { randomUUID } from 'node:crypto';
|
|
228
|
+
import type { AuthorizationPrincipal } from '@empire-builder-kit/runtime/authorization';
|
|
229
|
+
import {
|
|
230
|
+
evaluateDataLifecycleHolds,
|
|
231
|
+
type DataLifecycleRuntimeHoldEffect,
|
|
232
|
+
type DataLifecycleRuntimeHoldPurpose,
|
|
233
|
+
type DataLifecycleRuntimeHoldV1,
|
|
234
|
+
} from '@empire-builder-kit/runtime/data-lifecycle';
|
|
235
|
+
import { productRecordReference } from './pseudonymous-reference.js';
|
|
236
|
+
import {
|
|
237
|
+
canonicalDataRightsNow,
|
|
238
|
+
requireDirectAdmin,
|
|
239
|
+
writeDataRightsAudit,
|
|
240
|
+
} from './data-rights-shared.js';
|
|
241
|
+
import { withPrimaryDatabase } from '../database/runtime-client.js';
|
|
242
|
+
|
|
243
|
+
const MAXIMUM_HOLD_LIFETIME_MS = 90 * 24 * 60 * 60 * 1_000;
|
|
244
|
+
|
|
245
|
+
function holdId(): string {
|
|
246
|
+
return 'hold.' + randomUUID().replaceAll('-', '');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function requireBoundedHoldInterval(
|
|
250
|
+
startsAt: string,
|
|
251
|
+
reviewAt: string,
|
|
252
|
+
expiresAt: string,
|
|
253
|
+
): void {
|
|
254
|
+
const start = Date.parse(startsAt);
|
|
255
|
+
const review = Date.parse(reviewAt);
|
|
256
|
+
const expiry = Date.parse(expiresAt);
|
|
257
|
+
if (
|
|
258
|
+
!Number.isFinite(start) ||
|
|
259
|
+
!Number.isFinite(review) ||
|
|
260
|
+
!Number.isFinite(expiry) ||
|
|
261
|
+
start >= review ||
|
|
262
|
+
review > expiry ||
|
|
263
|
+
expiry - start > MAXIMUM_HOLD_LIFETIME_MS
|
|
264
|
+
) {
|
|
265
|
+
throw new Error(
|
|
266
|
+
'Record hold requires a future review and a lifetime of at most 90 days.',
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function createRecordHold(
|
|
272
|
+
input: {
|
|
273
|
+
effects: readonly DataLifecycleRuntimeHoldEffect[];
|
|
274
|
+
expiresAt: string;
|
|
275
|
+
principal: AuthorizationPrincipal;
|
|
276
|
+
purpose: DataLifecycleRuntimeHoldPurpose;
|
|
277
|
+
recordId: string;
|
|
278
|
+
reviewAt: string;
|
|
279
|
+
},
|
|
280
|
+
now = canonicalDataRightsNow(),
|
|
281
|
+
): DataLifecycleRuntimeHoldV1 {
|
|
282
|
+
requireDirectAdmin(input.principal);
|
|
283
|
+
requireBoundedHoldInterval(now, input.reviewAt, input.expiresAt);
|
|
284
|
+
const hold: DataLifecycleRuntimeHoldV1 = {
|
|
285
|
+
categoryId: 'product-record',
|
|
286
|
+
effects: [...input.effects],
|
|
287
|
+
expiresAt: input.expiresAt,
|
|
288
|
+
holdId: holdId(),
|
|
289
|
+
issuerCode: 'direct-admin',
|
|
290
|
+
purpose: input.purpose,
|
|
291
|
+
reviewAt: input.reviewAt,
|
|
292
|
+
scopeRef: productRecordReference(input.recordId),
|
|
293
|
+
startsAt: now,
|
|
294
|
+
status: 'active',
|
|
295
|
+
};
|
|
296
|
+
for (const operation of ['deletion', 'export'] as const) {
|
|
297
|
+
evaluateDataLifecycleHolds({
|
|
298
|
+
categoryId: hold.categoryId,
|
|
299
|
+
holds: [hold],
|
|
300
|
+
now,
|
|
301
|
+
operation,
|
|
302
|
+
scopeRef: hold.scopeRef,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
return hold;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export async function issueRecordHold(input: {
|
|
309
|
+
correlationId: string;
|
|
310
|
+
effects: readonly DataLifecycleRuntimeHoldEffect[];
|
|
311
|
+
expiresAt: string;
|
|
312
|
+
now?: string;
|
|
313
|
+
principal: AuthorizationPrincipal;
|
|
314
|
+
purpose: DataLifecycleRuntimeHoldPurpose;
|
|
315
|
+
recordId: string;
|
|
316
|
+
reviewAt: string;
|
|
317
|
+
}): Promise<DataLifecycleRuntimeHoldV1> {
|
|
318
|
+
const now = canonicalDataRightsNow(input.now);
|
|
319
|
+
const hold = createRecordHold(input, now);
|
|
320
|
+
return withPrimaryDatabase(
|
|
321
|
+
{ correlationId: input.correlationId },
|
|
322
|
+
'api-database-records',
|
|
323
|
+
async (client) => {
|
|
324
|
+
await client.query('BEGIN');
|
|
325
|
+
try {
|
|
326
|
+
const record = await client.query(
|
|
327
|
+
'SELECT 1 FROM records WHERE id = $1::uuid FOR UPDATE',
|
|
328
|
+
[input.recordId],
|
|
329
|
+
);
|
|
330
|
+
if (record.rowCount !== 1) {
|
|
331
|
+
throw new Error('The held record was not found.');
|
|
332
|
+
}
|
|
333
|
+
await client.query(
|
|
334
|
+
\`INSERT INTO data_lifecycle_holds
|
|
335
|
+
(hold_id, scope_ref, category_id, hold_json, status, expires_at,
|
|
336
|
+
review_at)
|
|
337
|
+
VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7)\`,
|
|
338
|
+
[
|
|
339
|
+
hold.holdId,
|
|
340
|
+
hold.scopeRef,
|
|
341
|
+
hold.categoryId,
|
|
342
|
+
JSON.stringify(hold),
|
|
343
|
+
hold.status,
|
|
344
|
+
hold.expiresAt,
|
|
345
|
+
hold.reviewAt,
|
|
346
|
+
],
|
|
347
|
+
);
|
|
348
|
+
await writeDataRightsAudit(client, {
|
|
349
|
+
action: 'hold',
|
|
350
|
+
correlationId: input.correlationId,
|
|
351
|
+
now,
|
|
352
|
+
outcome: 'succeeded',
|
|
353
|
+
principal: input.principal,
|
|
354
|
+
purposeCode: 'hold-issue',
|
|
355
|
+
recordId: input.recordId,
|
|
356
|
+
summary: 'A bounded record hold was issued.',
|
|
357
|
+
});
|
|
358
|
+
await client.query('COMMIT');
|
|
359
|
+
return hold;
|
|
360
|
+
} catch (error) {
|
|
361
|
+
await client.query('ROLLBACK').catch(() => undefined);
|
|
362
|
+
throw error;
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function lockedRecordHold(
|
|
369
|
+
client: import('pg').Client,
|
|
370
|
+
recordId: string,
|
|
371
|
+
holdIdValue: string,
|
|
372
|
+
): Promise<DataLifecycleRuntimeHoldV1> {
|
|
373
|
+
const result = await client.query<{
|
|
374
|
+
holdJson: DataLifecycleRuntimeHoldV1;
|
|
375
|
+
}>(
|
|
376
|
+
\`SELECT hold_json AS "holdJson"
|
|
377
|
+
FROM data_lifecycle_holds
|
|
378
|
+
WHERE hold_id = $1
|
|
379
|
+
AND category_id = 'product-record'
|
|
380
|
+
AND scope_ref = $2
|
|
381
|
+
FOR UPDATE\`,
|
|
382
|
+
[holdIdValue, productRecordReference(recordId)],
|
|
383
|
+
);
|
|
384
|
+
const hold = result.rows[0]?.holdJson;
|
|
385
|
+
if (!hold) throw new Error('The record hold was not found.');
|
|
386
|
+
return hold;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function persistRecordHold(
|
|
390
|
+
client: import('pg').Client,
|
|
391
|
+
hold: DataLifecycleRuntimeHoldV1,
|
|
392
|
+
): Promise<void> {
|
|
393
|
+
const result = await client.query(
|
|
394
|
+
\`UPDATE data_lifecycle_holds
|
|
395
|
+
SET hold_json = $2::jsonb,
|
|
396
|
+
status = $3,
|
|
397
|
+
expires_at = $4,
|
|
398
|
+
review_at = $5
|
|
399
|
+
WHERE hold_id = $1\`,
|
|
400
|
+
[
|
|
401
|
+
hold.holdId,
|
|
402
|
+
JSON.stringify(hold),
|
|
403
|
+
hold.status,
|
|
404
|
+
hold.expiresAt,
|
|
405
|
+
hold.reviewAt,
|
|
406
|
+
],
|
|
407
|
+
);
|
|
408
|
+
if (result.rowCount !== 1) throw new Error('Record hold update was lost.');
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export async function reviewRecordHold(input: {
|
|
412
|
+
correlationId: string;
|
|
413
|
+
expiresAt: string;
|
|
414
|
+
holdId: string;
|
|
415
|
+
now?: string;
|
|
416
|
+
principal: AuthorizationPrincipal;
|
|
417
|
+
recordId: string;
|
|
418
|
+
reviewAt: string;
|
|
419
|
+
}): Promise<DataLifecycleRuntimeHoldV1> {
|
|
420
|
+
requireDirectAdmin(input.principal);
|
|
421
|
+
const now = canonicalDataRightsNow(input.now);
|
|
422
|
+
return withPrimaryDatabase(
|
|
423
|
+
{ correlationId: input.correlationId },
|
|
424
|
+
'api-database-records',
|
|
425
|
+
async (client) => {
|
|
426
|
+
await client.query('BEGIN');
|
|
427
|
+
try {
|
|
428
|
+
const current = await lockedRecordHold(
|
|
429
|
+
client,
|
|
430
|
+
input.recordId,
|
|
431
|
+
input.holdId,
|
|
432
|
+
);
|
|
433
|
+
if (
|
|
434
|
+
current.status !== 'active' ||
|
|
435
|
+
Date.parse(now) >= Date.parse(current.expiresAt)
|
|
436
|
+
) {
|
|
437
|
+
throw new Error('Only an unexpired active hold can be reviewed.');
|
|
438
|
+
}
|
|
439
|
+
requireBoundedHoldInterval(
|
|
440
|
+
current.startsAt,
|
|
441
|
+
input.reviewAt,
|
|
442
|
+
input.expiresAt,
|
|
443
|
+
);
|
|
444
|
+
if (Date.parse(input.reviewAt) <= Date.parse(now)) {
|
|
445
|
+
throw new Error('Reviewed hold must set a future review time.');
|
|
446
|
+
}
|
|
447
|
+
const hold: DataLifecycleRuntimeHoldV1 = {
|
|
448
|
+
...current,
|
|
449
|
+
expiresAt: input.expiresAt,
|
|
450
|
+
reviewAt: input.reviewAt,
|
|
451
|
+
};
|
|
452
|
+
evaluateDataLifecycleHolds({
|
|
453
|
+
categoryId: hold.categoryId,
|
|
454
|
+
holds: [hold],
|
|
455
|
+
now,
|
|
456
|
+
operation: 'export',
|
|
457
|
+
scopeRef: hold.scopeRef,
|
|
458
|
+
});
|
|
459
|
+
await persistRecordHold(client, hold);
|
|
460
|
+
await writeDataRightsAudit(client, {
|
|
461
|
+
action: 'hold',
|
|
462
|
+
correlationId: input.correlationId,
|
|
463
|
+
now,
|
|
464
|
+
outcome: 'succeeded',
|
|
465
|
+
principal: input.principal,
|
|
466
|
+
purposeCode: 'hold-review',
|
|
467
|
+
recordId: input.recordId,
|
|
468
|
+
summary: 'A bounded record hold was reviewed.',
|
|
469
|
+
});
|
|
470
|
+
await client.query('COMMIT');
|
|
471
|
+
return hold;
|
|
472
|
+
} catch (error) {
|
|
473
|
+
await client.query('ROLLBACK').catch(() => undefined);
|
|
474
|
+
throw error;
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export async function releaseRecordHold(input: {
|
|
481
|
+
correlationId: string;
|
|
482
|
+
holdId: string;
|
|
483
|
+
now?: string;
|
|
484
|
+
principal: AuthorizationPrincipal;
|
|
485
|
+
recordId: string;
|
|
486
|
+
}): Promise<DataLifecycleRuntimeHoldV1> {
|
|
487
|
+
requireDirectAdmin(input.principal);
|
|
488
|
+
const now = canonicalDataRightsNow(input.now);
|
|
489
|
+
return withPrimaryDatabase(
|
|
490
|
+
{ correlationId: input.correlationId },
|
|
491
|
+
'api-database-records',
|
|
492
|
+
async (client) => {
|
|
493
|
+
await client.query('BEGIN');
|
|
494
|
+
try {
|
|
495
|
+
const current = await lockedRecordHold(
|
|
496
|
+
client,
|
|
497
|
+
input.recordId,
|
|
498
|
+
input.holdId,
|
|
499
|
+
);
|
|
500
|
+
if (
|
|
501
|
+
current.status !== 'active' ||
|
|
502
|
+
Date.parse(now) > Date.parse(current.expiresAt)
|
|
503
|
+
) {
|
|
504
|
+
throw new Error('Only an active record hold can be released.');
|
|
505
|
+
}
|
|
506
|
+
const hold: DataLifecycleRuntimeHoldV1 = {
|
|
507
|
+
...current,
|
|
508
|
+
releasedAt: now,
|
|
509
|
+
status: 'released',
|
|
510
|
+
};
|
|
511
|
+
evaluateDataLifecycleHolds({
|
|
512
|
+
categoryId: hold.categoryId,
|
|
513
|
+
holds: [hold],
|
|
514
|
+
now,
|
|
515
|
+
operation: 'export',
|
|
516
|
+
scopeRef: hold.scopeRef,
|
|
517
|
+
});
|
|
518
|
+
await persistRecordHold(client, hold);
|
|
519
|
+
await writeDataRightsAudit(client, {
|
|
520
|
+
action: 'hold',
|
|
521
|
+
correlationId: input.correlationId,
|
|
522
|
+
now,
|
|
523
|
+
outcome: 'succeeded',
|
|
524
|
+
principal: input.principal,
|
|
525
|
+
purposeCode: 'hold-release',
|
|
526
|
+
recordId: input.recordId,
|
|
527
|
+
summary: 'A bounded record hold was released.',
|
|
528
|
+
});
|
|
529
|
+
await client.query('COMMIT');
|
|
530
|
+
return hold;
|
|
531
|
+
} catch (error) {
|
|
532
|
+
await client.query('ROLLBACK').catch(() => undefined);
|
|
533
|
+
throw error;
|
|
534
|
+
}
|
|
535
|
+
},
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export async function expireRecordHold(input: {
|
|
540
|
+
correlationId: string;
|
|
541
|
+
holdId: string;
|
|
542
|
+
now?: string;
|
|
543
|
+
principal: AuthorizationPrincipal;
|
|
544
|
+
recordId: string;
|
|
545
|
+
}): Promise<DataLifecycleRuntimeHoldV1> {
|
|
546
|
+
requireDirectAdmin(input.principal);
|
|
547
|
+
const now = canonicalDataRightsNow(input.now);
|
|
548
|
+
return withPrimaryDatabase(
|
|
549
|
+
{ correlationId: input.correlationId },
|
|
550
|
+
'api-database-records',
|
|
551
|
+
async (client) => {
|
|
552
|
+
await client.query('BEGIN');
|
|
553
|
+
try {
|
|
554
|
+
const current = await lockedRecordHold(
|
|
555
|
+
client,
|
|
556
|
+
input.recordId,
|
|
557
|
+
input.holdId,
|
|
558
|
+
);
|
|
559
|
+
if (
|
|
560
|
+
current.status !== 'active' ||
|
|
561
|
+
Date.parse(now) < Date.parse(current.expiresAt)
|
|
562
|
+
) {
|
|
563
|
+
throw new Error('Record hold is not due for expiry.');
|
|
564
|
+
}
|
|
565
|
+
const hold: DataLifecycleRuntimeHoldV1 = {
|
|
566
|
+
...current,
|
|
567
|
+
releasedAt: current.expiresAt,
|
|
568
|
+
status: 'released',
|
|
569
|
+
};
|
|
570
|
+
evaluateDataLifecycleHolds({
|
|
571
|
+
categoryId: hold.categoryId,
|
|
572
|
+
holds: [hold],
|
|
573
|
+
now,
|
|
574
|
+
operation: 'export',
|
|
575
|
+
scopeRef: hold.scopeRef,
|
|
576
|
+
});
|
|
577
|
+
await persistRecordHold(client, hold);
|
|
578
|
+
await writeDataRightsAudit(client, {
|
|
579
|
+
action: 'hold',
|
|
580
|
+
correlationId: input.correlationId,
|
|
581
|
+
now,
|
|
582
|
+
outcome: 'succeeded',
|
|
583
|
+
principal: input.principal,
|
|
584
|
+
purposeCode: 'hold-expiry',
|
|
585
|
+
recordId: input.recordId,
|
|
586
|
+
summary: 'An expired record hold was closed.',
|
|
587
|
+
});
|
|
588
|
+
await client.query('COMMIT');
|
|
589
|
+
return hold;
|
|
590
|
+
} catch (error) {
|
|
591
|
+
await client.query('ROLLBACK').catch(() => undefined);
|
|
592
|
+
throw error;
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
`,
|
|
598
|
+
'data/record-correction.ts': `import {
|
|
599
|
+
EventBridgeClient,
|
|
600
|
+
PutEventsCommand,
|
|
601
|
+
type PutEventsRequestEntry,
|
|
602
|
+
} from '@aws-sdk/client-eventbridge';
|
|
603
|
+
import type { AuthorizationPrincipal } from '@empire-builder-kit/runtime/authorization';
|
|
604
|
+
import {
|
|
605
|
+
createDataLifecycleLedgerEntry,
|
|
606
|
+
lifecycleIdempotencyKey,
|
|
607
|
+
} from '@empire-builder-kit/runtime/data-lifecycle';
|
|
608
|
+
import { buildEventEntry } from '@empire-builder-kit/runtime/events';
|
|
609
|
+
import {
|
|
610
|
+
${pascalName}DataLifecycleChanged,
|
|
611
|
+
} from '../contracts/events.js';
|
|
612
|
+
import {
|
|
613
|
+
create${pascalName}DataLifecycleChanged,
|
|
614
|
+
} from './lifecycle-event.js';
|
|
615
|
+
import { productRecordReference } from './pseudonymous-reference.js';
|
|
616
|
+
import {
|
|
617
|
+
canonicalDataRightsNow,
|
|
618
|
+
DATA_RIGHTS_POLICY_VERSION,
|
|
619
|
+
effectiveRecordOwner,
|
|
620
|
+
plusDataRightsDays,
|
|
621
|
+
writeDataRightsAudit,
|
|
622
|
+
} from './data-rights-shared.js';
|
|
623
|
+
import { withPrimaryDatabase } from '../database/runtime-client.js';
|
|
624
|
+
|
|
625
|
+
const eventBridge = new EventBridgeClient({});
|
|
626
|
+
|
|
627
|
+
export interface RecordCorrectionInput {
|
|
628
|
+
correlationId: string;
|
|
629
|
+
name: string;
|
|
630
|
+
now?: string;
|
|
631
|
+
principal: AuthorizationPrincipal;
|
|
632
|
+
recordId: string;
|
|
633
|
+
runScope: string;
|
|
634
|
+
testExpiresAt?: string;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export function createRecordCorrectionArtifacts(input: RecordCorrectionInput) {
|
|
638
|
+
const now = canonicalDataRightsNow(input.now);
|
|
639
|
+
const recordRef = productRecordReference(input.recordId);
|
|
640
|
+
const requestRef = lifecycleIdempotencyKey('record-correction', [
|
|
641
|
+
recordRef,
|
|
642
|
+
input.correlationId,
|
|
643
|
+
input.name,
|
|
644
|
+
input.runScope,
|
|
645
|
+
input.testExpiresAt ?? 'none',
|
|
646
|
+
]);
|
|
647
|
+
const ledger = createDataLifecycleLedgerEntry({
|
|
648
|
+
action: 'correction',
|
|
649
|
+
categoryId: 'product-record',
|
|
650
|
+
effectiveAt: now,
|
|
651
|
+
expiresAt: plusDataRightsDays(now, 400),
|
|
652
|
+
policyVersion: DATA_RIGHTS_POLICY_VERSION,
|
|
653
|
+
pseudonymousKey: recordRef,
|
|
654
|
+
});
|
|
655
|
+
const data = create${pascalName}DataLifecycleChanged({
|
|
656
|
+
action: 'correction',
|
|
657
|
+
occurredAt: now,
|
|
658
|
+
policyVersion: DATA_RIGHTS_POLICY_VERSION,
|
|
659
|
+
recordId: input.recordId,
|
|
660
|
+
requestRef,
|
|
661
|
+
});
|
|
662
|
+
const built = buildEventEntry(${pascalName}DataLifecycleChanged, data, {
|
|
663
|
+
busName: process.env.EVENT_BUS_NAME ?? '',
|
|
664
|
+
context: { correlationId: input.correlationId },
|
|
665
|
+
eventId: requestRef,
|
|
666
|
+
occurredAt: now,
|
|
667
|
+
stage: process.env.EBK_STAGE ?? 'development',
|
|
668
|
+
});
|
|
669
|
+
const event: PutEventsRequestEntry = {
|
|
670
|
+
Detail: built.entry.detail,
|
|
671
|
+
DetailType: built.entry.detailType,
|
|
672
|
+
EventBusName: built.entry.eventBusName,
|
|
673
|
+
Source: built.entry.source,
|
|
674
|
+
};
|
|
675
|
+
return { event, ledger, now, recordRef, requestRef };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
export async function correctRecordWithLifecycle(
|
|
679
|
+
input: RecordCorrectionInput,
|
|
680
|
+
): Promise<{
|
|
681
|
+
id: string;
|
|
682
|
+
name: string;
|
|
683
|
+
propagation: 'pending';
|
|
684
|
+
requestRef: string;
|
|
685
|
+
runScope: string;
|
|
686
|
+
}> {
|
|
687
|
+
const artifacts = createRecordCorrectionArtifacts(input);
|
|
688
|
+
const ownerId = effectiveRecordOwner(input.principal);
|
|
689
|
+
return withPrimaryDatabase(
|
|
690
|
+
{ correlationId: input.correlationId },
|
|
691
|
+
'api-database-records',
|
|
692
|
+
async (client) => {
|
|
693
|
+
await client.query('BEGIN');
|
|
694
|
+
try {
|
|
695
|
+
const recorded = await client.query(
|
|
696
|
+
\`INSERT INTO data_lifecycle_ledger
|
|
697
|
+
(entry_id, action, category_id, effective_at, expires_at,
|
|
698
|
+
idempotency_key, policy_version, pseudonymous_key)
|
|
699
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
700
|
+
ON CONFLICT (idempotency_key) DO NOTHING
|
|
701
|
+
RETURNING entry_id\`,
|
|
702
|
+
[
|
|
703
|
+
artifacts.ledger.entryId,
|
|
704
|
+
artifacts.ledger.action,
|
|
705
|
+
artifacts.ledger.categoryId,
|
|
706
|
+
artifacts.ledger.effectiveAt,
|
|
707
|
+
artifacts.ledger.expiresAt,
|
|
708
|
+
artifacts.requestRef,
|
|
709
|
+
artifacts.ledger.policyVersion,
|
|
710
|
+
artifacts.ledger.pseudonymousKey,
|
|
711
|
+
],
|
|
712
|
+
);
|
|
713
|
+
const result =
|
|
714
|
+
recorded.rowCount === 1
|
|
715
|
+
? await client.query<{
|
|
716
|
+
id: string;
|
|
717
|
+
name: string;
|
|
718
|
+
runScope: string;
|
|
719
|
+
}>(
|
|
720
|
+
\`UPDATE records
|
|
721
|
+
SET name = $2,
|
|
722
|
+
run_scope = $3,
|
|
723
|
+
last_activity_at = $4,
|
|
724
|
+
test_expires_at =
|
|
725
|
+
CASE WHEN $5::boolean
|
|
726
|
+
THEN $6::timestamptz
|
|
727
|
+
ELSE test_expires_at
|
|
728
|
+
END
|
|
729
|
+
WHERE id = $1::uuid AND owner_id = $7
|
|
730
|
+
RETURNING id::text, name, run_scope AS "runScope"\`,
|
|
731
|
+
[
|
|
732
|
+
input.recordId,
|
|
733
|
+
input.name,
|
|
734
|
+
input.runScope,
|
|
735
|
+
artifacts.now,
|
|
736
|
+
input.testExpiresAt !== undefined,
|
|
737
|
+
input.testExpiresAt ?? null,
|
|
738
|
+
ownerId,
|
|
739
|
+
],
|
|
740
|
+
)
|
|
741
|
+
: await client.query<{
|
|
742
|
+
id: string;
|
|
743
|
+
name: string;
|
|
744
|
+
runScope: string;
|
|
745
|
+
}>(
|
|
746
|
+
\`SELECT id::text, name, run_scope AS "runScope"
|
|
747
|
+
FROM records
|
|
748
|
+
WHERE id = $1::uuid AND owner_id = $2\`,
|
|
749
|
+
[input.recordId, ownerId],
|
|
750
|
+
);
|
|
751
|
+
const record = result.rows[0];
|
|
752
|
+
if (!record) {
|
|
753
|
+
throw new Error('The correction target was not found.');
|
|
754
|
+
}
|
|
755
|
+
await client.query(
|
|
756
|
+
\`INSERT INTO data_lifecycle_outbox
|
|
757
|
+
(idempotency_key, event_json, expires_at)
|
|
758
|
+
VALUES ($1, $2::jsonb, $3)
|
|
759
|
+
ON CONFLICT (idempotency_key) DO NOTHING\`,
|
|
760
|
+
[
|
|
761
|
+
artifacts.requestRef,
|
|
762
|
+
JSON.stringify(artifacts.event),
|
|
763
|
+
plusDataRightsDays(artifacts.now, 30),
|
|
764
|
+
],
|
|
765
|
+
);
|
|
766
|
+
if (recorded.rowCount === 1) {
|
|
767
|
+
await writeDataRightsAudit(client, {
|
|
768
|
+
action: 'correction',
|
|
769
|
+
correlationId: input.correlationId,
|
|
770
|
+
now: artifacts.now,
|
|
771
|
+
outcome: 'succeeded',
|
|
772
|
+
principal: input.principal,
|
|
773
|
+
purposeCode: 'record-correction',
|
|
774
|
+
recordId: input.recordId,
|
|
775
|
+
summary:
|
|
776
|
+
'A record correction and its downstream event were committed.',
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
await client.query('COMMIT');
|
|
780
|
+
return {
|
|
781
|
+
...record,
|
|
782
|
+
propagation: 'pending' as const,
|
|
783
|
+
requestRef: artifacts.requestRef,
|
|
784
|
+
};
|
|
785
|
+
} catch (error) {
|
|
786
|
+
await client.query('ROLLBACK').catch(() => undefined);
|
|
787
|
+
throw error;
|
|
788
|
+
}
|
|
789
|
+
},
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
export async function publishRecordCorrection(
|
|
794
|
+
correlationId: string,
|
|
795
|
+
requestRef: string,
|
|
796
|
+
): Promise<'published' | 'retry-required'> {
|
|
797
|
+
const pending = await withPrimaryDatabase(
|
|
798
|
+
{ correlationId },
|
|
799
|
+
'api-database-records',
|
|
800
|
+
(client) =>
|
|
801
|
+
client.query<{ eventJson: PutEventsRequestEntry; publishedAt: Date | null }>(
|
|
802
|
+
\`SELECT event_json AS "eventJson", published_at AS "publishedAt"
|
|
803
|
+
FROM data_lifecycle_outbox
|
|
804
|
+
WHERE idempotency_key = $1\`,
|
|
805
|
+
[requestRef],
|
|
806
|
+
),
|
|
807
|
+
);
|
|
808
|
+
const row = pending.rows[0];
|
|
809
|
+
if (!row) throw new Error('The correction outbox record was not found.');
|
|
810
|
+
if (row.publishedAt) return 'published';
|
|
811
|
+
try {
|
|
812
|
+
const response = await eventBridge.send(
|
|
813
|
+
new PutEventsCommand({ Entries: [row.eventJson] }),
|
|
814
|
+
);
|
|
815
|
+
if ((response.FailedEntryCount ?? 0) !== 0) {
|
|
816
|
+
throw new Error('event-entry-rejected');
|
|
817
|
+
}
|
|
818
|
+
await withPrimaryDatabase(
|
|
819
|
+
{ correlationId },
|
|
820
|
+
'api-database-records',
|
|
821
|
+
(client) =>
|
|
822
|
+
client.query(
|
|
823
|
+
\`UPDATE data_lifecycle_outbox
|
|
824
|
+
SET attempts = attempts + 1,
|
|
825
|
+
last_error_code = NULL,
|
|
826
|
+
published_at = now()
|
|
827
|
+
WHERE idempotency_key = $1 AND published_at IS NULL\`,
|
|
828
|
+
[requestRef],
|
|
829
|
+
),
|
|
830
|
+
);
|
|
831
|
+
return 'published';
|
|
832
|
+
} catch {
|
|
833
|
+
await withPrimaryDatabase(
|
|
834
|
+
{ correlationId },
|
|
835
|
+
'api-database-records',
|
|
836
|
+
(client) =>
|
|
837
|
+
client.query(
|
|
838
|
+
\`UPDATE data_lifecycle_outbox
|
|
839
|
+
SET attempts = attempts + 1,
|
|
840
|
+
last_error_code = 'event-publication-failed'
|
|
841
|
+
WHERE idempotency_key = $1 AND published_at IS NULL\`,
|
|
842
|
+
[requestRef],
|
|
843
|
+
),
|
|
844
|
+
);
|
|
845
|
+
return 'retry-required';
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
`,
|
|
849
|
+
'data/record-export.ts': renderRecordExportSource(name, pascalName),
|
|
850
|
+
'integration/data-rights-deadline-lock-order.spec.ts': renderDataRightsDeadlineLockOrderIntegrationSource(name),
|
|
851
|
+
'jobs/data-export-cleanup.ts': `import { randomUUID } from 'node:crypto';
|
|
852
|
+
import type { AuthorizationPrincipal } from '@empire-builder-kit/runtime/authorization';
|
|
853
|
+
import {
|
|
854
|
+
createMetricEmitter,
|
|
855
|
+
type MetricEmitter,
|
|
856
|
+
} from '@empire-builder-kit/runtime/observability';
|
|
857
|
+
import {
|
|
858
|
+
${identifierPrefix}AuthorizationActions,
|
|
859
|
+
} from '../authorization/policy.js';
|
|
860
|
+
import { authorize${pascalName}InternalOperation } from '../authorization/runtime.js';
|
|
861
|
+
import {
|
|
862
|
+
cleanupDueRecordExports,
|
|
863
|
+
reportExportCleanupDeadlineBreaches,
|
|
864
|
+
} from '../data/record-export.js';
|
|
865
|
+
|
|
866
|
+
const WORKLOAD_IDENTITY = '${name}:data-export-cleanup';
|
|
867
|
+
const MINIMUM_REMAINING_TIME_MS = 30_000;
|
|
868
|
+
|
|
869
|
+
export interface DataExportCleanupDependencies {
|
|
870
|
+
authorize: typeof authorize${pascalName}InternalOperation;
|
|
871
|
+
cleanup: typeof cleanupDueRecordExports;
|
|
872
|
+
metrics: MetricEmitter;
|
|
873
|
+
report: typeof reportExportCleanupDeadlineBreaches;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function cleanupPrincipal(): AuthorizationPrincipal {
|
|
877
|
+
const configured = process.env.EBK_WORKLOAD_IDENTITY ?? '';
|
|
878
|
+
if (configured !== WORKLOAD_IDENTITY) {
|
|
879
|
+
throw new Error('Data export cleanup workload identity is invalid.');
|
|
880
|
+
}
|
|
881
|
+
return {
|
|
882
|
+
actor: { id: configured, type: 'service' },
|
|
883
|
+
audience: configured,
|
|
884
|
+
authority: { machineIdentityId: configured, mode: 'machine' },
|
|
885
|
+
kind: 'agent',
|
|
886
|
+
roles: [],
|
|
887
|
+
scopes: [],
|
|
888
|
+
subject: { id: configured, type: 'service' },
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function emitCleanupDeadlineBreaches(
|
|
893
|
+
metrics: MetricEmitter,
|
|
894
|
+
value: number,
|
|
895
|
+
) {
|
|
896
|
+
return metrics.emit({
|
|
897
|
+
dimensions: {
|
|
898
|
+
component: 'data-export-cleanup',
|
|
899
|
+
feature: 'data-lifecycle',
|
|
900
|
+
operation: 'expiry-cleanup',
|
|
901
|
+
slice: '${name}',
|
|
902
|
+
stage: process.env.EBK_STAGE ?? 'local',
|
|
903
|
+
},
|
|
904
|
+
metricName: 'DataExportCleanupDeadlineBreaches',
|
|
905
|
+
namespace: 'EBK/DataLifecycle',
|
|
906
|
+
unit: 'Count',
|
|
907
|
+
value,
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
export function createDataExportCleanupHandler(
|
|
912
|
+
dependencies: DataExportCleanupDependencies,
|
|
913
|
+
) {
|
|
914
|
+
return async (
|
|
915
|
+
event: { id?: string } = {},
|
|
916
|
+
context: { getRemainingTimeInMillis(): number },
|
|
917
|
+
) => {
|
|
918
|
+
const correlationId = event.id ?? randomUUID();
|
|
919
|
+
const principal = cleanupPrincipal();
|
|
920
|
+
await dependencies.authorize({
|
|
921
|
+
action: ${identifierPrefix}AuthorizationActions.dataExportCleanup,
|
|
922
|
+
correlationId,
|
|
923
|
+
operationId: '${name}.cleanup-due-record-exports',
|
|
924
|
+
workloadIdentity: principal.actor.id,
|
|
925
|
+
});
|
|
926
|
+
let reportedDeadlineBreaches = 0;
|
|
927
|
+
let reportContinuationRequired = false;
|
|
928
|
+
let reportFailure: unknown;
|
|
929
|
+
const reportPendingBreaches = async () => {
|
|
930
|
+
try {
|
|
931
|
+
const reported = await dependencies.report({
|
|
932
|
+
correlationId,
|
|
933
|
+
report: (value) =>
|
|
934
|
+
emitCleanupDeadlineBreaches(dependencies.metrics, value) !==
|
|
935
|
+
undefined,
|
|
936
|
+
});
|
|
937
|
+
reportedDeadlineBreaches += reported.reported;
|
|
938
|
+
reportContinuationRequired ||= reported.continuationRequired;
|
|
939
|
+
} catch (error) {
|
|
940
|
+
reportFailure ??= error;
|
|
941
|
+
}
|
|
942
|
+
};
|
|
943
|
+
await reportPendingBreaches();
|
|
944
|
+
let result: Awaited<ReturnType<typeof cleanupDueRecordExports>> | undefined;
|
|
945
|
+
let cleanupFailure: unknown;
|
|
946
|
+
try {
|
|
947
|
+
result = await dependencies.cleanup({
|
|
948
|
+
correlationId,
|
|
949
|
+
maximumBatches: 1,
|
|
950
|
+
principal,
|
|
951
|
+
shouldContinue: () =>
|
|
952
|
+
context.getRemainingTimeInMillis() > MINIMUM_REMAINING_TIME_MS,
|
|
953
|
+
});
|
|
954
|
+
} catch (error) {
|
|
955
|
+
cleanupFailure = error;
|
|
956
|
+
}
|
|
957
|
+
await reportPendingBreaches();
|
|
958
|
+
if (reportFailure !== undefined) {
|
|
959
|
+
throw reportFailure;
|
|
960
|
+
}
|
|
961
|
+
if (reportedDeadlineBreaches > 0 || reportContinuationRequired) {
|
|
962
|
+
throw new Error('confidential-export-cleanup-deadline-breached');
|
|
963
|
+
}
|
|
964
|
+
if (cleanupFailure !== undefined) {
|
|
965
|
+
throw cleanupFailure;
|
|
966
|
+
}
|
|
967
|
+
if (!result) {
|
|
968
|
+
throw new Error('Confidential export cleanup returned no result.');
|
|
969
|
+
}
|
|
970
|
+
if (result.continuationRequired) {
|
|
971
|
+
throw new Error(
|
|
972
|
+
'Confidential export cleanup requires scheduled retry continuation.',
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
return result;
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
export const handler = createDataExportCleanupHandler({
|
|
980
|
+
authorize: authorize${pascalName}InternalOperation,
|
|
981
|
+
cleanup: cleanupDueRecordExports,
|
|
982
|
+
metrics: createMetricEmitter(),
|
|
983
|
+
report: reportExportCleanupDeadlineBreaches,
|
|
984
|
+
});
|
|
985
|
+
`,
|
|
986
|
+
'testing/data-rights-workflows.spec.ts': renderDataRightsTestSource(name, pascalName),
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
function renderRecordExportSource(name, pascalName) {
|
|
990
|
+
const identifierPrefix = (0, built_in_plan_shared_js_1.lowerCamelIdentifier)(pascalName);
|
|
991
|
+
return `import {
|
|
992
|
+
DeleteObjectsCommand,
|
|
993
|
+
GetObjectCommand,
|
|
994
|
+
HeadObjectCommand,
|
|
995
|
+
ListObjectVersionsCommand,
|
|
996
|
+
PutObjectCommand,
|
|
997
|
+
S3Client,
|
|
998
|
+
} from '@aws-sdk/client-s3';
|
|
999
|
+
import {
|
|
1000
|
+
assertAuthorizationAllowed,
|
|
1001
|
+
beginApprovalExecution,
|
|
1002
|
+
completeApprovalExecution,
|
|
1003
|
+
createApprovalRequest,
|
|
1004
|
+
decideApprovalRequest,
|
|
1005
|
+
type ApprovalRequest,
|
|
1006
|
+
type AuthorizationPrincipal,
|
|
1007
|
+
} from '@empire-builder-kit/runtime/authorization';
|
|
1008
|
+
import {
|
|
1009
|
+
createDataLifecycleExportRequest,
|
|
1010
|
+
evaluateDataLifecycleHolds,
|
|
1011
|
+
transitionDataLifecycleExport,
|
|
1012
|
+
type DataLifecycleExportRequestV1,
|
|
1013
|
+
} from '@empire-builder-kit/runtime/data-lifecycle';
|
|
1014
|
+
import type { Client as PgClient } from 'pg';
|
|
1015
|
+
import {
|
|
1016
|
+
${identifierPrefix}AuthorizationPolicy,
|
|
1017
|
+
type ${pascalName}AuthorizationAction,
|
|
1018
|
+
} from '../authorization/policy.js';
|
|
1019
|
+
import {
|
|
1020
|
+
approvalReference,
|
|
1021
|
+
productRecordReference,
|
|
1022
|
+
} from './pseudonymous-reference.js';
|
|
1023
|
+
import {
|
|
1024
|
+
canonicalDataRightsNow,
|
|
1025
|
+
DATA_RIGHTS_POLICY_VERSION,
|
|
1026
|
+
dataRightsArtifactReference,
|
|
1027
|
+
dataRightsSubjectReference,
|
|
1028
|
+
loadActiveRecordHolds,
|
|
1029
|
+
plusDataRightsHours,
|
|
1030
|
+
requireDirectAdmin,
|
|
1031
|
+
requireExportCleanupAuthority,
|
|
1032
|
+
requireDirectHuman,
|
|
1033
|
+
writeDataRightsAudit,
|
|
1034
|
+
} from './data-rights-shared.js';
|
|
1035
|
+
import { withPrimaryDatabase } from '../database/runtime-client.js';
|
|
1036
|
+
|
|
1037
|
+
const EXPORT_ACTION = '${name}:record-export' as ${pascalName}AuthorizationAction;
|
|
1038
|
+
const EXPORT_SOURCE_COPY = 'primary-database';
|
|
1039
|
+
const CONFIDENTIAL_EXPORT_CLEANUP_FEATURE =
|
|
1040
|
+
'confidential-export';
|
|
1041
|
+
const DEFAULT_EXPORT_CLEANUP_PAGE_SIZE = 25;
|
|
1042
|
+
const DEFAULT_EXPORT_CLEANUP_REPORT_PAGE_SIZE = 100;
|
|
1043
|
+
const MAXIMUM_PENDING_EXPORT_CLEANUP_REPORTS = ${PENDING_EXPORT_CLEANUP_REPORT_BOUND_LITERAL};
|
|
1044
|
+
const MAXIMUM_EXPORTS_PER_SUBJECT_PER_DAY = 5;
|
|
1045
|
+
const MAXIMUM_OUTSTANDING_CONFIDENTIAL_EXPORTS = 20;
|
|
1046
|
+
const APPROVAL_POLICY_VERSION =
|
|
1047
|
+
${identifierPrefix}AuthorizationPolicy.version;
|
|
1048
|
+
const s3 = new S3Client({});
|
|
1049
|
+
|
|
1050
|
+
if (
|
|
1051
|
+
MAXIMUM_OUTSTANDING_CONFIDENTIAL_EXPORTS >=
|
|
1052
|
+
DEFAULT_EXPORT_CLEANUP_PAGE_SIZE
|
|
1053
|
+
) {
|
|
1054
|
+
throw new Error(
|
|
1055
|
+
'The global confidential-export bound must fit inside one cleanup page.',
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
export interface RecordExportObjectStore {
|
|
1060
|
+
send(command: unknown): Promise<unknown>;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
const defaultRecordExportObjectStore: RecordExportObjectStore = {
|
|
1064
|
+
send: (command) => s3.send(command as never),
|
|
1065
|
+
};
|
|
1066
|
+
|
|
1067
|
+
type ProductApproval = ApprovalRequest<${pascalName}AuthorizationAction>;
|
|
1068
|
+
|
|
1069
|
+
interface ExportWorkflow {
|
|
1070
|
+
approval: ProductApproval;
|
|
1071
|
+
artifactRef: string | null;
|
|
1072
|
+
cleanupDeadlineAt: Date | null;
|
|
1073
|
+
cleanupState: string;
|
|
1074
|
+
currentVersionId: string | null;
|
|
1075
|
+
expiresAt: Date | null;
|
|
1076
|
+
lifecycle: DataLifecycleExportRequestV1;
|
|
1077
|
+
objectKey: string;
|
|
1078
|
+
recordRef: string;
|
|
1079
|
+
subjectRef: string;
|
|
1080
|
+
uploadStartedAt: Date | null;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function exportCommand(recordId: string) {
|
|
1084
|
+
return {
|
|
1085
|
+
format: 'application/json',
|
|
1086
|
+
recordRef: productRecordReference(recordId),
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
export function recordExportObjectKey(approvalRequestId: string): string {
|
|
1091
|
+
if (!/^[0-9a-f-]{36}$/i.test(approvalRequestId)) {
|
|
1092
|
+
throw new Error('Export object key requires an approval UUID.');
|
|
1093
|
+
}
|
|
1094
|
+
return 'confidential-exports/' + approvalRequestId + '/record.json';
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
export function recordExportExpiresAt(now: string): string {
|
|
1098
|
+
// Leave bounded intervals for the hourly scan, EventBridge delivery retry,
|
|
1099
|
+
// and Lambda asynchronous execution retry inside the 24-hour maximum.
|
|
1100
|
+
// The S3 lifecycle rule remains defense in depth, not the deadline.
|
|
1101
|
+
return plusDataRightsHours(canonicalDataRightsNow(now), 21);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
export function recordExportCleanupDeadlineAt(now: string): string {
|
|
1105
|
+
return plusDataRightsHours(canonicalDataRightsNow(now), 24);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
export function recordExportSchedule(
|
|
1109
|
+
uploadStartedAt: string,
|
|
1110
|
+
persisted: {
|
|
1111
|
+
cleanupDeadlineAt?: string | null;
|
|
1112
|
+
expiresAt?: string | null;
|
|
1113
|
+
uploadStartedAt?: string | null;
|
|
1114
|
+
} = {},
|
|
1115
|
+
): {
|
|
1116
|
+
cleanupDeadlineAt: string;
|
|
1117
|
+
expiresAt: string;
|
|
1118
|
+
uploadStartedAt: string;
|
|
1119
|
+
} {
|
|
1120
|
+
const persistedScheduleProvided =
|
|
1121
|
+
persisted.uploadStartedAt != null ||
|
|
1122
|
+
persisted.expiresAt != null ||
|
|
1123
|
+
persisted.cleanupDeadlineAt != null;
|
|
1124
|
+
if (
|
|
1125
|
+
persistedScheduleProvided &&
|
|
1126
|
+
(typeof persisted.uploadStartedAt !== 'string' ||
|
|
1127
|
+
typeof persisted.expiresAt !== 'string' ||
|
|
1128
|
+
typeof persisted.cleanupDeadlineAt !== 'string')
|
|
1129
|
+
) {
|
|
1130
|
+
throw new Error(
|
|
1131
|
+
'Persisted confidential-export cleanup schedule is incomplete.',
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
uploadStartedAt = canonicalDataRightsNow(
|
|
1135
|
+
persisted.uploadStartedAt ?? uploadStartedAt,
|
|
1136
|
+
);
|
|
1137
|
+
const expectedExpiresAt = recordExportExpiresAt(uploadStartedAt);
|
|
1138
|
+
const expectedCleanupDeadlineAt =
|
|
1139
|
+
recordExportCleanupDeadlineAt(uploadStartedAt);
|
|
1140
|
+
const expiresAt = persisted.expiresAt ?? expectedExpiresAt;
|
|
1141
|
+
const cleanupDeadlineAt =
|
|
1142
|
+
persisted.cleanupDeadlineAt ?? expectedCleanupDeadlineAt;
|
|
1143
|
+
if (
|
|
1144
|
+
expiresAt !== expectedExpiresAt ||
|
|
1145
|
+
cleanupDeadlineAt !== expectedCleanupDeadlineAt
|
|
1146
|
+
) {
|
|
1147
|
+
throw new Error(
|
|
1148
|
+
'Persisted confidential-export cleanup schedule is inconsistent.',
|
|
1149
|
+
);
|
|
1150
|
+
}
|
|
1151
|
+
return { cleanupDeadlineAt, expiresAt, uploadStartedAt };
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function exactRecordExportSchedule(
|
|
1155
|
+
workflow: ExportWorkflow,
|
|
1156
|
+
fallbackUploadStartedAt: string,
|
|
1157
|
+
): ReturnType<typeof recordExportSchedule> {
|
|
1158
|
+
return recordExportSchedule(
|
|
1159
|
+
fallbackUploadStartedAt,
|
|
1160
|
+
{
|
|
1161
|
+
cleanupDeadlineAt: workflow.cleanupDeadlineAt?.toISOString() ?? null,
|
|
1162
|
+
expiresAt: workflow.expiresAt?.toISOString() ?? null,
|
|
1163
|
+
uploadStartedAt: workflow.uploadStartedAt?.toISOString() ?? null,
|
|
1164
|
+
},
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function exportResources(): { bucketName: string; kmsKeyArn: string } {
|
|
1169
|
+
const bucketName = process.env.DATA_EXPORT_BUCKET_NAME?.trim();
|
|
1170
|
+
const kmsKeyArn = process.env.DATA_EXPORT_KMS_KEY_ARN?.trim();
|
|
1171
|
+
if (!bucketName || !kmsKeyArn || !kmsKeyArn.startsWith('arn:aws:kms:')) {
|
|
1172
|
+
throw new Error(
|
|
1173
|
+
'Confidential export requires linked DATA_EXPORT_BUCKET_NAME and DATA_EXPORT_KMS_KEY_ARN.',
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
return { bucketName, kmsKeyArn };
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
async function loadExportWorkflow(
|
|
1180
|
+
client: PgClient,
|
|
1181
|
+
approvalRequestId: string,
|
|
1182
|
+
): Promise<ExportWorkflow | undefined> {
|
|
1183
|
+
const result = await client.query<ExportWorkflow>(
|
|
1184
|
+
\`SELECT a.request_json AS approval,
|
|
1185
|
+
d.request_json AS lifecycle,
|
|
1186
|
+
e.artifact_ref AS "artifactRef",
|
|
1187
|
+
e.cleanup_deadline_at AS "cleanupDeadlineAt",
|
|
1188
|
+
e.cleanup_state AS "cleanupState",
|
|
1189
|
+
e.current_version_id AS "currentVersionId",
|
|
1190
|
+
e.expires_at AS "expiresAt",
|
|
1191
|
+
e.object_key AS "objectKey",
|
|
1192
|
+
e.record_ref AS "recordRef",
|
|
1193
|
+
e.subject_ref AS "subjectRef",
|
|
1194
|
+
e.upload_started_at AS "uploadStartedAt"
|
|
1195
|
+
FROM approval_requests a
|
|
1196
|
+
JOIN data_lifecycle_requests d
|
|
1197
|
+
ON d.approval_request_id = a.id
|
|
1198
|
+
JOIN data_lifecycle_export_artifacts e
|
|
1199
|
+
ON e.approval_request_id = a.id
|
|
1200
|
+
WHERE a.id = $1::uuid
|
|
1201
|
+
FOR UPDATE OF a, d, e\`,
|
|
1202
|
+
[approvalRequestId],
|
|
1203
|
+
);
|
|
1204
|
+
return result.rows[0];
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
async function persistApproval(
|
|
1208
|
+
client: PgClient,
|
|
1209
|
+
previousRevision: number,
|
|
1210
|
+
approval: ProductApproval,
|
|
1211
|
+
): Promise<void> {
|
|
1212
|
+
const result = await client.query(
|
|
1213
|
+
\`UPDATE approval_requests
|
|
1214
|
+
SET expires_at = $3,
|
|
1215
|
+
policy_version = $4,
|
|
1216
|
+
request_digest = $5,
|
|
1217
|
+
request_json = $6::jsonb,
|
|
1218
|
+
revision = $7,
|
|
1219
|
+
status = $8
|
|
1220
|
+
WHERE id = $1::uuid AND revision = $2\`,
|
|
1221
|
+
[
|
|
1222
|
+
approval.id,
|
|
1223
|
+
previousRevision,
|
|
1224
|
+
approval.expiresAt,
|
|
1225
|
+
approval.policyVersion,
|
|
1226
|
+
approval.requestDigest,
|
|
1227
|
+
JSON.stringify(approval),
|
|
1228
|
+
approval.revision,
|
|
1229
|
+
approval.status,
|
|
1230
|
+
],
|
|
1231
|
+
);
|
|
1232
|
+
if (result.rowCount !== 1) throw new Error('Approval compare-and-set failed.');
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
async function persistExportState(
|
|
1236
|
+
client: PgClient,
|
|
1237
|
+
approvalRequestId: string,
|
|
1238
|
+
state: DataLifecycleExportRequestV1,
|
|
1239
|
+
): Promise<void> {
|
|
1240
|
+
const result = await client.query(
|
|
1241
|
+
\`UPDATE data_lifecycle_requests
|
|
1242
|
+
SET request_json = $2::jsonb,
|
|
1243
|
+
revision = revision + 1,
|
|
1244
|
+
status = $3,
|
|
1245
|
+
updated_at = $4
|
|
1246
|
+
WHERE approval_request_id = $1::uuid\`,
|
|
1247
|
+
[
|
|
1248
|
+
approvalRequestId,
|
|
1249
|
+
JSON.stringify(state),
|
|
1250
|
+
state.status,
|
|
1251
|
+
state.updatedAt,
|
|
1252
|
+
],
|
|
1253
|
+
);
|
|
1254
|
+
if (result.rowCount !== 1) {
|
|
1255
|
+
throw new Error('Export lifecycle compare-and-set failed.');
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
export async function requestAuthorizedRecordExport(input: {
|
|
1260
|
+
correlationId: string;
|
|
1261
|
+
now?: string;
|
|
1262
|
+
principal: AuthorizationPrincipal;
|
|
1263
|
+
recordId: string;
|
|
1264
|
+
}): Promise<{
|
|
1265
|
+
approvalRequestId: string;
|
|
1266
|
+
expiresAt: string;
|
|
1267
|
+
requestDigest: string;
|
|
1268
|
+
status: 'pending';
|
|
1269
|
+
}> {
|
|
1270
|
+
requireDirectHuman(input.principal);
|
|
1271
|
+
const now = canonicalDataRightsNow(input.now);
|
|
1272
|
+
const recordRef = productRecordReference(input.recordId);
|
|
1273
|
+
const subjectRef = dataRightsSubjectReference(input.principal.actor.id);
|
|
1274
|
+
const approval = createApprovalRequest<${pascalName}AuthorizationAction>({
|
|
1275
|
+
action: EXPORT_ACTION,
|
|
1276
|
+
author: input.principal,
|
|
1277
|
+
command: exportCommand(input.recordId),
|
|
1278
|
+
expiresAt: plusDataRightsHours(now, 1),
|
|
1279
|
+
now,
|
|
1280
|
+
policyVersion: APPROVAL_POLICY_VERSION,
|
|
1281
|
+
resource: { id: recordRef, type: 'record' },
|
|
1282
|
+
slice: '${name}',
|
|
1283
|
+
});
|
|
1284
|
+
const lifecycle = createDataLifecycleExportRequest({
|
|
1285
|
+
approvalRequirement: 'required',
|
|
1286
|
+
categoryId: 'product-record',
|
|
1287
|
+
createdAt: now,
|
|
1288
|
+
policyVersion: DATA_RIGHTS_POLICY_VERSION,
|
|
1289
|
+
requestRef: approvalReference(approval.id),
|
|
1290
|
+
scopeRef: recordRef,
|
|
1291
|
+
sourceCopyIds: [EXPORT_SOURCE_COPY],
|
|
1292
|
+
});
|
|
1293
|
+
await withPrimaryDatabase(
|
|
1294
|
+
{ correlationId: input.correlationId },
|
|
1295
|
+
'api-database-records',
|
|
1296
|
+
async (client) => {
|
|
1297
|
+
await client.query('BEGIN');
|
|
1298
|
+
try {
|
|
1299
|
+
await client.query(
|
|
1300
|
+
\`SELECT pg_advisory_xact_lock(
|
|
1301
|
+
hashtextextended('data-export-outstanding', 0)
|
|
1302
|
+
)\`,
|
|
1303
|
+
);
|
|
1304
|
+
await client.query(
|
|
1305
|
+
\`SELECT pg_advisory_xact_lock(
|
|
1306
|
+
hashtextextended('data-export-rate:' || $1, 0)
|
|
1307
|
+
)\`,
|
|
1308
|
+
[subjectRef],
|
|
1309
|
+
);
|
|
1310
|
+
const record = await client.query(
|
|
1311
|
+
\`SELECT 1
|
|
1312
|
+
FROM records
|
|
1313
|
+
WHERE id = $1::uuid AND owner_id = $2
|
|
1314
|
+
FOR UPDATE\`,
|
|
1315
|
+
[input.recordId, input.principal.actor.id],
|
|
1316
|
+
);
|
|
1317
|
+
if (record.rowCount !== 1) {
|
|
1318
|
+
throw new Error('The export target was not found.');
|
|
1319
|
+
}
|
|
1320
|
+
const outstanding = await client.query<{ count: string }>(
|
|
1321
|
+
\`SELECT count(*)::text AS count
|
|
1322
|
+
FROM data_lifecycle_export_artifacts
|
|
1323
|
+
WHERE cleanup_state <> 'completed'\`,
|
|
1324
|
+
);
|
|
1325
|
+
if (
|
|
1326
|
+
Number(outstanding.rows[0]?.count ?? 0) >=
|
|
1327
|
+
MAXIMUM_OUTSTANDING_CONFIDENTIAL_EXPORTS
|
|
1328
|
+
) {
|
|
1329
|
+
throw new Error(
|
|
1330
|
+
'The bounded global confidential-export capacity was exceeded.',
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
const rate = await client.query<{ count: string }>(
|
|
1334
|
+
\`SELECT count(*)::text AS count
|
|
1335
|
+
FROM data_lifecycle_export_artifacts
|
|
1336
|
+
WHERE subject_ref = $1
|
|
1337
|
+
AND created_at >= $2::timestamptz - INTERVAL '24 hours'\`,
|
|
1338
|
+
[subjectRef, now],
|
|
1339
|
+
);
|
|
1340
|
+
if (
|
|
1341
|
+
Number(rate.rows[0]?.count ?? 0) >=
|
|
1342
|
+
MAXIMUM_EXPORTS_PER_SUBJECT_PER_DAY
|
|
1343
|
+
) {
|
|
1344
|
+
throw new Error('The bounded subject export rate was exceeded.');
|
|
1345
|
+
}
|
|
1346
|
+
await client.query(
|
|
1347
|
+
\`INSERT INTO approval_requests
|
|
1348
|
+
(id, author_reference, expires_at, policy_version,
|
|
1349
|
+
request_digest, request_json, revision, status)
|
|
1350
|
+
VALUES ($1::uuid, $2, $3, $4, $5, $6::jsonb, $7, $8)\`,
|
|
1351
|
+
[
|
|
1352
|
+
approval.id,
|
|
1353
|
+
subjectRef,
|
|
1354
|
+
approval.expiresAt,
|
|
1355
|
+
approval.policyVersion,
|
|
1356
|
+
approval.requestDigest,
|
|
1357
|
+
JSON.stringify(approval),
|
|
1358
|
+
approval.revision,
|
|
1359
|
+
approval.status,
|
|
1360
|
+
],
|
|
1361
|
+
);
|
|
1362
|
+
await client.query(
|
|
1363
|
+
\`INSERT INTO data_lifecycle_requests
|
|
1364
|
+
(id, approval_request_id, request_json, revision, status,
|
|
1365
|
+
updated_at)
|
|
1366
|
+
VALUES (gen_random_uuid(), $1::uuid, $2::jsonb, 0, $3, $4)\`,
|
|
1367
|
+
[approval.id, JSON.stringify(lifecycle), lifecycle.status, now],
|
|
1368
|
+
);
|
|
1369
|
+
await client.query(
|
|
1370
|
+
\`INSERT INTO data_lifecycle_export_artifacts
|
|
1371
|
+
(approval_request_id, cleanup_state, object_key, record_ref,
|
|
1372
|
+
subject_ref, updated_at)
|
|
1373
|
+
VALUES ($1::uuid, 'requested', $2, $3, $4, $5)\`,
|
|
1374
|
+
[
|
|
1375
|
+
approval.id,
|
|
1376
|
+
recordExportObjectKey(approval.id),
|
|
1377
|
+
recordRef,
|
|
1378
|
+
subjectRef,
|
|
1379
|
+
now,
|
|
1380
|
+
],
|
|
1381
|
+
);
|
|
1382
|
+
await writeDataRightsAudit(client, {
|
|
1383
|
+
action: 'export',
|
|
1384
|
+
approvalRequestId: approval.id,
|
|
1385
|
+
correlationId: input.correlationId,
|
|
1386
|
+
now,
|
|
1387
|
+
outcome: 'succeeded',
|
|
1388
|
+
principal: input.principal,
|
|
1389
|
+
purposeCode: 'export-request',
|
|
1390
|
+
recordId: input.recordId,
|
|
1391
|
+
summary: 'A subject-scoped confidential export was requested.',
|
|
1392
|
+
});
|
|
1393
|
+
await client.query('COMMIT');
|
|
1394
|
+
} catch (error) {
|
|
1395
|
+
await client.query('ROLLBACK').catch(() => undefined);
|
|
1396
|
+
throw error;
|
|
1397
|
+
}
|
|
1398
|
+
},
|
|
1399
|
+
);
|
|
1400
|
+
return {
|
|
1401
|
+
approvalRequestId: approval.id,
|
|
1402
|
+
expiresAt: approval.expiresAt,
|
|
1403
|
+
requestDigest: approval.requestDigest,
|
|
1404
|
+
status: 'pending',
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
export async function decideRecordExport(input: {
|
|
1409
|
+
approvalRequestId: string;
|
|
1410
|
+
correlationId: string;
|
|
1411
|
+
expectedRequestDigest: string;
|
|
1412
|
+
now?: string;
|
|
1413
|
+
outcome: 'approved' | 'rejected';
|
|
1414
|
+
principal: AuthorizationPrincipal;
|
|
1415
|
+
reasonReference: string;
|
|
1416
|
+
recordId: string;
|
|
1417
|
+
}): Promise<{ status: 'approved' | 'rejected' }> {
|
|
1418
|
+
requireDirectAdmin(input.principal);
|
|
1419
|
+
const now = canonicalDataRightsNow(input.now);
|
|
1420
|
+
const status = await withPrimaryDatabase(
|
|
1421
|
+
{ correlationId: input.correlationId },
|
|
1422
|
+
'api-database-records',
|
|
1423
|
+
async (client) => {
|
|
1424
|
+
await client.query('BEGIN');
|
|
1425
|
+
try {
|
|
1426
|
+
const workflow = await loadExportWorkflow(
|
|
1427
|
+
client,
|
|
1428
|
+
input.approvalRequestId,
|
|
1429
|
+
);
|
|
1430
|
+
if (
|
|
1431
|
+
!workflow ||
|
|
1432
|
+
workflow.approval.action !== EXPORT_ACTION ||
|
|
1433
|
+
workflow.recordRef !== productRecordReference(input.recordId)
|
|
1434
|
+
) {
|
|
1435
|
+
throw new Error('The export approval was not found.');
|
|
1436
|
+
}
|
|
1437
|
+
const transition = decideApprovalRequest(workflow.approval, {
|
|
1438
|
+
approver: input.principal,
|
|
1439
|
+
expectedRequestDigest: input.expectedRequestDigest,
|
|
1440
|
+
now,
|
|
1441
|
+
outcome: input.outcome,
|
|
1442
|
+
reasonReference: input.reasonReference,
|
|
1443
|
+
requiredPolicyVersion: APPROVAL_POLICY_VERSION,
|
|
1444
|
+
});
|
|
1445
|
+
assertAuthorizationAllowed(transition.decision);
|
|
1446
|
+
await persistApproval(
|
|
1447
|
+
client,
|
|
1448
|
+
workflow.approval.revision,
|
|
1449
|
+
transition.request,
|
|
1450
|
+
);
|
|
1451
|
+
if (transition.request.status === 'rejected') {
|
|
1452
|
+
const lifecycle = transitionDataLifecycleExport(
|
|
1453
|
+
workflow.lifecycle,
|
|
1454
|
+
{
|
|
1455
|
+
at: now,
|
|
1456
|
+
reasonCode: 'approval-rejected',
|
|
1457
|
+
type: 'reject',
|
|
1458
|
+
},
|
|
1459
|
+
);
|
|
1460
|
+
await persistExportState(
|
|
1461
|
+
client,
|
|
1462
|
+
input.approvalRequestId,
|
|
1463
|
+
lifecycle,
|
|
1464
|
+
);
|
|
1465
|
+
const artifact = await client.query(
|
|
1466
|
+
\`UPDATE data_lifecycle_export_artifacts
|
|
1467
|
+
SET cleanup_state = 'completed',
|
|
1468
|
+
last_error_code = NULL,
|
|
1469
|
+
updated_at = $2
|
|
1470
|
+
WHERE approval_request_id = $1::uuid
|
|
1471
|
+
AND cleanup_state = 'requested'
|
|
1472
|
+
AND upload_started_at IS NULL
|
|
1473
|
+
AND current_version_id IS NULL\`,
|
|
1474
|
+
[input.approvalRequestId, now],
|
|
1475
|
+
);
|
|
1476
|
+
if (artifact.rowCount !== 1) {
|
|
1477
|
+
throw new Error(
|
|
1478
|
+
'Rejected export artifact lost compare-and-set.',
|
|
1479
|
+
);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
await writeDataRightsAudit(client, {
|
|
1483
|
+
action: 'export',
|
|
1484
|
+
approvalRequestId: input.approvalRequestId,
|
|
1485
|
+
correlationId: input.correlationId,
|
|
1486
|
+
now,
|
|
1487
|
+
outcome:
|
|
1488
|
+
transition.request.status === 'approved'
|
|
1489
|
+
? 'succeeded'
|
|
1490
|
+
: 'rejected',
|
|
1491
|
+
principal: input.principal,
|
|
1492
|
+
purposeCode: 'export-decision',
|
|
1493
|
+
recordId: input.recordId,
|
|
1494
|
+
summary: 'A non-author decided a confidential export request.',
|
|
1495
|
+
});
|
|
1496
|
+
await client.query('COMMIT');
|
|
1497
|
+
return transition.request.status;
|
|
1498
|
+
} catch (error) {
|
|
1499
|
+
await client.query('ROLLBACK').catch(() => undefined);
|
|
1500
|
+
throw error;
|
|
1501
|
+
}
|
|
1502
|
+
},
|
|
1503
|
+
);
|
|
1504
|
+
return { status: status as 'approved' | 'rejected' };
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
interface PreparedRecordExport {
|
|
1508
|
+
approval: ProductApproval;
|
|
1509
|
+
cleanupDeadlineAt: string;
|
|
1510
|
+
expiresAt: string;
|
|
1511
|
+
lifecycle: DataLifecycleExportRequestV1;
|
|
1512
|
+
objectKey: string;
|
|
1513
|
+
uploadStartedAt: string;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
async function prepareRecordExport(input: {
|
|
1517
|
+
approvalRequestId: string;
|
|
1518
|
+
correlationId: string;
|
|
1519
|
+
now: string;
|
|
1520
|
+
principal: AuthorizationPrincipal;
|
|
1521
|
+
recordId: string;
|
|
1522
|
+
}): Promise<PreparedRecordExport | { held: true }> {
|
|
1523
|
+
requireDirectHuman(input.principal);
|
|
1524
|
+
return withPrimaryDatabase(
|
|
1525
|
+
{ correlationId: input.correlationId },
|
|
1526
|
+
'api-database-records',
|
|
1527
|
+
async (client) => {
|
|
1528
|
+
await client.query('BEGIN');
|
|
1529
|
+
try {
|
|
1530
|
+
const workflow = await loadExportWorkflow(
|
|
1531
|
+
client,
|
|
1532
|
+
input.approvalRequestId,
|
|
1533
|
+
);
|
|
1534
|
+
if (
|
|
1535
|
+
!workflow ||
|
|
1536
|
+
workflow.approval.action !== EXPORT_ACTION ||
|
|
1537
|
+
workflow.recordRef !== productRecordReference(input.recordId) ||
|
|
1538
|
+
workflow.approval.author.actorId !== input.principal.actor.id
|
|
1539
|
+
) {
|
|
1540
|
+
throw new Error('The export workflow was not found.');
|
|
1541
|
+
}
|
|
1542
|
+
if (
|
|
1543
|
+
workflow.lifecycle.status === 'ready' &&
|
|
1544
|
+
workflow.approval.status === 'executed'
|
|
1545
|
+
) {
|
|
1546
|
+
const schedule = exactRecordExportSchedule(workflow, input.now);
|
|
1547
|
+
if (
|
|
1548
|
+
workflow.lifecycle.artifact?.expiresAt !== schedule.expiresAt
|
|
1549
|
+
) {
|
|
1550
|
+
throw new Error(
|
|
1551
|
+
'Published confidential-export expiry is inconsistent.',
|
|
1552
|
+
);
|
|
1553
|
+
}
|
|
1554
|
+
await client.query('COMMIT');
|
|
1555
|
+
return {
|
|
1556
|
+
approval: workflow.approval,
|
|
1557
|
+
cleanupDeadlineAt: schedule.cleanupDeadlineAt,
|
|
1558
|
+
expiresAt: schedule.expiresAt,
|
|
1559
|
+
lifecycle: workflow.lifecycle,
|
|
1560
|
+
objectKey: workflow.objectKey,
|
|
1561
|
+
uploadStartedAt: schedule.uploadStartedAt,
|
|
1562
|
+
};
|
|
1563
|
+
}
|
|
1564
|
+
const record = await client.query(
|
|
1565
|
+
\`SELECT 1
|
|
1566
|
+
FROM records
|
|
1567
|
+
WHERE id = $1::uuid AND owner_id = $2
|
|
1568
|
+
FOR UPDATE\`,
|
|
1569
|
+
[input.recordId, workflow.approval.author.actorId],
|
|
1570
|
+
);
|
|
1571
|
+
if (record.rowCount !== 1) {
|
|
1572
|
+
throw new Error('The export target was not found.');
|
|
1573
|
+
}
|
|
1574
|
+
let lifecycle = workflow.lifecycle;
|
|
1575
|
+
if (lifecycle.status === 'requested' || lifecycle.status === 'held') {
|
|
1576
|
+
const holds = await loadActiveRecordHolds(
|
|
1577
|
+
client,
|
|
1578
|
+
input.recordId,
|
|
1579
|
+
input.now,
|
|
1580
|
+
'export',
|
|
1581
|
+
);
|
|
1582
|
+
lifecycle = transitionDataLifecycleExport(lifecycle, {
|
|
1583
|
+
approvalRef: approvalReference(input.approvalRequestId),
|
|
1584
|
+
at: input.now,
|
|
1585
|
+
authorityRef: dataRightsSubjectReference(
|
|
1586
|
+
input.principal.actor.id,
|
|
1587
|
+
),
|
|
1588
|
+
holds,
|
|
1589
|
+
type: 'verify',
|
|
1590
|
+
});
|
|
1591
|
+
await persistExportState(
|
|
1592
|
+
client,
|
|
1593
|
+
input.approvalRequestId,
|
|
1594
|
+
lifecycle,
|
|
1595
|
+
);
|
|
1596
|
+
if (lifecycle.status === 'held') {
|
|
1597
|
+
await writeDataRightsAudit(client, {
|
|
1598
|
+
action: 'export',
|
|
1599
|
+
approvalRequestId: input.approvalRequestId,
|
|
1600
|
+
correlationId: input.correlationId,
|
|
1601
|
+
now: input.now,
|
|
1602
|
+
outcome: 'held',
|
|
1603
|
+
principal: input.principal,
|
|
1604
|
+
purposeCode: 'export-execution',
|
|
1605
|
+
recordId: input.recordId,
|
|
1606
|
+
summary: 'An active record hold blocked confidential export.',
|
|
1607
|
+
});
|
|
1608
|
+
await client.query('COMMIT');
|
|
1609
|
+
return { held: true };
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
const executionId = 'export:' + input.approvalRequestId;
|
|
1613
|
+
let approval = workflow.approval;
|
|
1614
|
+
if (approval.status === 'approved') {
|
|
1615
|
+
const begun = beginApprovalExecution(approval, {
|
|
1616
|
+
action: EXPORT_ACTION,
|
|
1617
|
+
command: exportCommand(input.recordId),
|
|
1618
|
+
executionId,
|
|
1619
|
+
now: input.now,
|
|
1620
|
+
requiredPolicyVersion: APPROVAL_POLICY_VERSION,
|
|
1621
|
+
resource: {
|
|
1622
|
+
id: productRecordReference(input.recordId),
|
|
1623
|
+
type: 'record',
|
|
1624
|
+
},
|
|
1625
|
+
});
|
|
1626
|
+
assertAuthorizationAllowed(begun.decision);
|
|
1627
|
+
await persistApproval(client, approval.revision, begun.request);
|
|
1628
|
+
approval = begun.request;
|
|
1629
|
+
} else if (
|
|
1630
|
+
approval.status !== 'executing' ||
|
|
1631
|
+
approval.execution?.executionId !== executionId
|
|
1632
|
+
) {
|
|
1633
|
+
throw new Error('The exact export approval is not executable.');
|
|
1634
|
+
}
|
|
1635
|
+
if (lifecycle.status === 'verified') {
|
|
1636
|
+
lifecycle = transitionDataLifecycleExport(lifecycle, {
|
|
1637
|
+
at: input.now,
|
|
1638
|
+
type: 'start-gathering',
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
const copy = lifecycle.sourceCopies.find(
|
|
1642
|
+
({ copyId }) => copyId === EXPORT_SOURCE_COPY,
|
|
1643
|
+
);
|
|
1644
|
+
if (copy?.status === 'pending' || copy?.status === 'failed') {
|
|
1645
|
+
lifecycle = transitionDataLifecycleExport(lifecycle, {
|
|
1646
|
+
at: input.now,
|
|
1647
|
+
copyId: EXPORT_SOURCE_COPY,
|
|
1648
|
+
type: 'start-copy',
|
|
1649
|
+
});
|
|
1650
|
+
}
|
|
1651
|
+
await persistExportState(
|
|
1652
|
+
client,
|
|
1653
|
+
input.approvalRequestId,
|
|
1654
|
+
lifecycle,
|
|
1655
|
+
);
|
|
1656
|
+
const schedule = exactRecordExportSchedule(workflow, input.now);
|
|
1657
|
+
await client.query(
|
|
1658
|
+
\`UPDATE data_lifecycle_export_artifacts
|
|
1659
|
+
SET cleanup_state = 'gathering',
|
|
1660
|
+
updated_at = $2,
|
|
1661
|
+
upload_started_at = COALESCE(upload_started_at, $2),
|
|
1662
|
+
expires_at = COALESCE(expires_at, $3),
|
|
1663
|
+
cleanup_deadline_at = COALESCE(cleanup_deadline_at, $4)
|
|
1664
|
+
WHERE approval_request_id = $1::uuid\`,
|
|
1665
|
+
[
|
|
1666
|
+
input.approvalRequestId,
|
|
1667
|
+
input.now,
|
|
1668
|
+
schedule.expiresAt,
|
|
1669
|
+
schedule.cleanupDeadlineAt,
|
|
1670
|
+
],
|
|
1671
|
+
);
|
|
1672
|
+
await client.query('COMMIT');
|
|
1673
|
+
return {
|
|
1674
|
+
approval,
|
|
1675
|
+
cleanupDeadlineAt: schedule.cleanupDeadlineAt,
|
|
1676
|
+
expiresAt: schedule.expiresAt,
|
|
1677
|
+
lifecycle,
|
|
1678
|
+
objectKey: workflow.objectKey,
|
|
1679
|
+
uploadStartedAt: schedule.uploadStartedAt,
|
|
1680
|
+
};
|
|
1681
|
+
} catch (error) {
|
|
1682
|
+
await client.query('ROLLBACK').catch(() => undefined);
|
|
1683
|
+
throw error;
|
|
1684
|
+
}
|
|
1685
|
+
},
|
|
1686
|
+
);
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
export async function putEncryptedExport(
|
|
1690
|
+
objectKey: string,
|
|
1691
|
+
body: string,
|
|
1692
|
+
objectStore: RecordExportObjectStore = defaultRecordExportObjectStore,
|
|
1693
|
+
): Promise<string> {
|
|
1694
|
+
const resources = exportResources();
|
|
1695
|
+
try {
|
|
1696
|
+
const result = (await objectStore.send(
|
|
1697
|
+
new PutObjectCommand({
|
|
1698
|
+
Body: body,
|
|
1699
|
+
Bucket: resources.bucketName,
|
|
1700
|
+
BucketKeyEnabled: true,
|
|
1701
|
+
ContentType: 'application/json',
|
|
1702
|
+
IfNoneMatch: '*',
|
|
1703
|
+
Key: objectKey,
|
|
1704
|
+
ServerSideEncryption: 'aws:kms',
|
|
1705
|
+
SSEKMSKeyId: resources.kmsKeyArn,
|
|
1706
|
+
}),
|
|
1707
|
+
)) as { VersionId?: string };
|
|
1708
|
+
if (!result.VersionId) {
|
|
1709
|
+
throw new Error('Versioned export put returned no version identifier.');
|
|
1710
|
+
}
|
|
1711
|
+
return result.VersionId;
|
|
1712
|
+
} catch (error) {
|
|
1713
|
+
const status = (error as { $metadata?: { httpStatusCode?: number } })
|
|
1714
|
+
.$metadata?.httpStatusCode;
|
|
1715
|
+
if (status !== 412) throw error;
|
|
1716
|
+
const existing = (await objectStore.send(
|
|
1717
|
+
new HeadObjectCommand({
|
|
1718
|
+
Bucket: resources.bucketName,
|
|
1719
|
+
Key: objectKey,
|
|
1720
|
+
}),
|
|
1721
|
+
)) as {
|
|
1722
|
+
SSEKMSKeyId?: string;
|
|
1723
|
+
ServerSideEncryption?: string;
|
|
1724
|
+
VersionId?: string;
|
|
1725
|
+
};
|
|
1726
|
+
if (
|
|
1727
|
+
!existing.VersionId ||
|
|
1728
|
+
existing.ServerSideEncryption !== 'aws:kms' ||
|
|
1729
|
+
existing.SSEKMSKeyId !== resources.kmsKeyArn
|
|
1730
|
+
) {
|
|
1731
|
+
throw new Error('Existing export object failed encryption validation.');
|
|
1732
|
+
}
|
|
1733
|
+
return existing.VersionId;
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
export async function executeApprovedRecordExport(input: {
|
|
1738
|
+
approvalRequestId: string;
|
|
1739
|
+
correlationId: string;
|
|
1740
|
+
now?: string;
|
|
1741
|
+
principal: AuthorizationPrincipal;
|
|
1742
|
+
recordId: string;
|
|
1743
|
+
}): Promise<
|
|
1744
|
+
| { status: 'held' }
|
|
1745
|
+
| {
|
|
1746
|
+
artifactRef: string;
|
|
1747
|
+
expiresAt: string;
|
|
1748
|
+
status: 'ready';
|
|
1749
|
+
}
|
|
1750
|
+
> {
|
|
1751
|
+
const now = canonicalDataRightsNow(input.now);
|
|
1752
|
+
const prepared = await prepareRecordExport({ ...input, now });
|
|
1753
|
+
if ('held' in prepared) return { status: 'held' };
|
|
1754
|
+
if (
|
|
1755
|
+
prepared.lifecycle.status === 'ready' &&
|
|
1756
|
+
prepared.lifecycle.artifact
|
|
1757
|
+
) {
|
|
1758
|
+
return {
|
|
1759
|
+
artifactRef: prepared.lifecycle.artifact.artifactRef,
|
|
1760
|
+
expiresAt: prepared.lifecycle.artifact.expiresAt,
|
|
1761
|
+
status: 'ready',
|
|
1762
|
+
};
|
|
1763
|
+
}
|
|
1764
|
+
const expiresAt = prepared.expiresAt;
|
|
1765
|
+
if (Date.parse(expiresAt) <= Date.parse(now)) {
|
|
1766
|
+
throw new Error(
|
|
1767
|
+
'The confidential export expired before durable publication.',
|
|
1768
|
+
);
|
|
1769
|
+
}
|
|
1770
|
+
return withPrimaryDatabase(
|
|
1771
|
+
{ correlationId: input.correlationId },
|
|
1772
|
+
'api-database-records',
|
|
1773
|
+
async (client) => {
|
|
1774
|
+
await client.query('BEGIN');
|
|
1775
|
+
try {
|
|
1776
|
+
const workflow = await loadExportWorkflow(
|
|
1777
|
+
client,
|
|
1778
|
+
input.approvalRequestId,
|
|
1779
|
+
);
|
|
1780
|
+
if (
|
|
1781
|
+
!workflow ||
|
|
1782
|
+
workflow.approval.status !== 'executing' ||
|
|
1783
|
+
workflow.approval.execution?.executionId !==
|
|
1784
|
+
'export:' + input.approvalRequestId ||
|
|
1785
|
+
workflow.lifecycle.status !== 'gathering' ||
|
|
1786
|
+
workflow.cleanupState !== 'gathering' ||
|
|
1787
|
+
workflow.expiresAt?.toISOString() !== expiresAt ||
|
|
1788
|
+
workflow.cleanupDeadlineAt?.toISOString() !==
|
|
1789
|
+
prepared.cleanupDeadlineAt
|
|
1790
|
+
) {
|
|
1791
|
+
throw new Error('The executing export approval changed.');
|
|
1792
|
+
}
|
|
1793
|
+
const record = await client.query<{
|
|
1794
|
+
id: string;
|
|
1795
|
+
name: string;
|
|
1796
|
+
runScope: string;
|
|
1797
|
+
}>(
|
|
1798
|
+
\`SELECT id::text, name, run_scope AS "runScope"
|
|
1799
|
+
FROM records
|
|
1800
|
+
WHERE id = $1::uuid AND owner_id = $2
|
|
1801
|
+
FOR UPDATE\`,
|
|
1802
|
+
[input.recordId, workflow.approval.author.actorId],
|
|
1803
|
+
);
|
|
1804
|
+
const value = record.rows[0];
|
|
1805
|
+
if (!value) throw new Error('The export target was not found.');
|
|
1806
|
+
const holds = await loadActiveRecordHolds(
|
|
1807
|
+
client,
|
|
1808
|
+
input.recordId,
|
|
1809
|
+
now,
|
|
1810
|
+
'export',
|
|
1811
|
+
);
|
|
1812
|
+
const holdEvaluation = evaluateDataLifecycleHolds({
|
|
1813
|
+
categoryId: 'product-record',
|
|
1814
|
+
holds,
|
|
1815
|
+
now,
|
|
1816
|
+
operation: 'export',
|
|
1817
|
+
scopeRef: productRecordReference(input.recordId),
|
|
1818
|
+
});
|
|
1819
|
+
if (holdEvaluation.blocked) {
|
|
1820
|
+
throw new Error('An active record hold blocks export publication.');
|
|
1821
|
+
}
|
|
1822
|
+
// Keep the workflow row locked across the versioned put. If the put
|
|
1823
|
+
// succeeds but a later database write fails, the durable gathering
|
|
1824
|
+
// row and its pre-persisted deadline remain available to cleanup.
|
|
1825
|
+
const versionId = await putEncryptedExport(
|
|
1826
|
+
prepared.objectKey,
|
|
1827
|
+
JSON.stringify(value),
|
|
1828
|
+
);
|
|
1829
|
+
const artifactRef = dataRightsArtifactReference(
|
|
1830
|
+
input.approvalRequestId + ':' + versionId,
|
|
1831
|
+
);
|
|
1832
|
+
let lifecycle = workflow.lifecycle;
|
|
1833
|
+
const activeCopy = lifecycle.sourceCopies.find(
|
|
1834
|
+
({ copyId }) => copyId === EXPORT_SOURCE_COPY,
|
|
1835
|
+
);
|
|
1836
|
+
if (activeCopy?.status === 'in-progress') {
|
|
1837
|
+
lifecycle = transitionDataLifecycleExport(lifecycle, {
|
|
1838
|
+
at: now,
|
|
1839
|
+
copyId: EXPORT_SOURCE_COPY,
|
|
1840
|
+
outcomeCode: 'gathered',
|
|
1841
|
+
type: 'complete-copy',
|
|
1842
|
+
});
|
|
1843
|
+
}
|
|
1844
|
+
lifecycle = transitionDataLifecycleExport(lifecycle, {
|
|
1845
|
+
artifactRef,
|
|
1846
|
+
at: now,
|
|
1847
|
+
encryption: 'approved-profile',
|
|
1848
|
+
expiresAt,
|
|
1849
|
+
type: 'publish',
|
|
1850
|
+
});
|
|
1851
|
+
const completed = completeApprovalExecution(workflow.approval, {
|
|
1852
|
+
executionId: 'export:' + input.approvalRequestId,
|
|
1853
|
+
now,
|
|
1854
|
+
requiredPolicyVersion: APPROVAL_POLICY_VERSION,
|
|
1855
|
+
});
|
|
1856
|
+
assertAuthorizationAllowed(completed.decision);
|
|
1857
|
+
await persistApproval(
|
|
1858
|
+
client,
|
|
1859
|
+
workflow.approval.revision,
|
|
1860
|
+
completed.request,
|
|
1861
|
+
);
|
|
1862
|
+
await persistExportState(
|
|
1863
|
+
client,
|
|
1864
|
+
input.approvalRequestId,
|
|
1865
|
+
lifecycle,
|
|
1866
|
+
);
|
|
1867
|
+
await client.query(
|
|
1868
|
+
\`UPDATE data_lifecycle_export_artifacts
|
|
1869
|
+
SET artifact_ref = $2,
|
|
1870
|
+
cleanup_state = 'ready',
|
|
1871
|
+
current_version_id = $3,
|
|
1872
|
+
expires_at = $4,
|
|
1873
|
+
updated_at = $5
|
|
1874
|
+
WHERE approval_request_id = $1::uuid\`,
|
|
1875
|
+
[input.approvalRequestId, artifactRef, versionId, expiresAt, now],
|
|
1876
|
+
);
|
|
1877
|
+
await client.query(
|
|
1878
|
+
\`INSERT INTO data_lifecycle_export_versions
|
|
1879
|
+
(approval_request_id, is_delete_marker, object_key, status,
|
|
1880
|
+
version_id)
|
|
1881
|
+
VALUES ($1::uuid, false, $2, 'available', $3)
|
|
1882
|
+
ON CONFLICT (approval_request_id, version_id)
|
|
1883
|
+
DO UPDATE SET status = 'available'\`,
|
|
1884
|
+
[input.approvalRequestId, prepared.objectKey, versionId],
|
|
1885
|
+
);
|
|
1886
|
+
await writeDataRightsAudit(client, {
|
|
1887
|
+
action: 'export',
|
|
1888
|
+
approvalRequestId: input.approvalRequestId,
|
|
1889
|
+
correlationId: input.correlationId,
|
|
1890
|
+
now,
|
|
1891
|
+
outcome: 'succeeded',
|
|
1892
|
+
principal: input.principal,
|
|
1893
|
+
purposeCode: 'export-execution',
|
|
1894
|
+
recordId: input.recordId,
|
|
1895
|
+
summary:
|
|
1896
|
+
'A KMS-encrypted subject export was created with bounded expiry.',
|
|
1897
|
+
});
|
|
1898
|
+
await client.query('COMMIT');
|
|
1899
|
+
return { artifactRef, expiresAt, status: 'ready' as const };
|
|
1900
|
+
} catch (error) {
|
|
1901
|
+
await client.query('ROLLBACK').catch(() => undefined);
|
|
1902
|
+
throw error;
|
|
1903
|
+
}
|
|
1904
|
+
},
|
|
1905
|
+
);
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
interface DeliveredRecordExport {
|
|
1909
|
+
artifactRef: string;
|
|
1910
|
+
deliveredAt: string;
|
|
1911
|
+
expiresAt: string;
|
|
1912
|
+
record: {
|
|
1913
|
+
id: string;
|
|
1914
|
+
name: string;
|
|
1915
|
+
runScope: string;
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
function parseDeliveredRecordExport(
|
|
1920
|
+
value: string,
|
|
1921
|
+
recordId: string,
|
|
1922
|
+
): DeliveredRecordExport['record'] {
|
|
1923
|
+
if (value.length > 64 * 1_024) {
|
|
1924
|
+
throw new Error('The confidential export artifact exceeds its bound.');
|
|
1925
|
+
}
|
|
1926
|
+
const candidate = JSON.parse(value) as {
|
|
1927
|
+
id?: unknown;
|
|
1928
|
+
name?: unknown;
|
|
1929
|
+
runScope?: unknown;
|
|
1930
|
+
};
|
|
1931
|
+
if (
|
|
1932
|
+
candidate.id !== recordId ||
|
|
1933
|
+
typeof candidate.name !== 'string' ||
|
|
1934
|
+
candidate.name.length < 1 ||
|
|
1935
|
+
candidate.name.length > 128 ||
|
|
1936
|
+
typeof candidate.runScope !== 'string' ||
|
|
1937
|
+
candidate.runScope.length < 1 ||
|
|
1938
|
+
candidate.runScope.length > 128
|
|
1939
|
+
) {
|
|
1940
|
+
throw new Error('The confidential export artifact is invalid.');
|
|
1941
|
+
}
|
|
1942
|
+
return {
|
|
1943
|
+
id: candidate.id,
|
|
1944
|
+
name: candidate.name,
|
|
1945
|
+
runScope: candidate.runScope,
|
|
1946
|
+
};
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
/**
|
|
1950
|
+
* Delivers an export through the authenticated API instead of exposing a
|
|
1951
|
+
* durable object URL. The workflow row, record, active holds, expiry, object
|
|
1952
|
+
* version, and author boundary are revalidated under one transaction, and the
|
|
1953
|
+
* successful download is committed to the Slice audit ledger before return.
|
|
1954
|
+
*/
|
|
1955
|
+
export async function deliverReadyRecordExport(input: {
|
|
1956
|
+
approvalRequestId: string;
|
|
1957
|
+
correlationId: string;
|
|
1958
|
+
now?: string;
|
|
1959
|
+
principal: AuthorizationPrincipal;
|
|
1960
|
+
recordId: string;
|
|
1961
|
+
}): Promise<DeliveredRecordExport> {
|
|
1962
|
+
requireDirectHuman(input.principal);
|
|
1963
|
+
const now = canonicalDataRightsNow(input.now);
|
|
1964
|
+
const resources = exportResources();
|
|
1965
|
+
return withPrimaryDatabase(
|
|
1966
|
+
{ correlationId: input.correlationId },
|
|
1967
|
+
'api-database-records',
|
|
1968
|
+
async (client) => {
|
|
1969
|
+
await client.query('BEGIN');
|
|
1970
|
+
try {
|
|
1971
|
+
const workflow = await loadExportWorkflow(
|
|
1972
|
+
client,
|
|
1973
|
+
input.approvalRequestId,
|
|
1974
|
+
);
|
|
1975
|
+
const artifact = workflow?.lifecycle.artifact;
|
|
1976
|
+
if (
|
|
1977
|
+
!workflow ||
|
|
1978
|
+
workflow.approval.action !== EXPORT_ACTION ||
|
|
1979
|
+
workflow.recordRef !== productRecordReference(input.recordId) ||
|
|
1980
|
+
workflow.approval.author.actorId !== input.principal.actor.id
|
|
1981
|
+
) {
|
|
1982
|
+
throw new Error('The export artifact was not found.');
|
|
1983
|
+
}
|
|
1984
|
+
if (
|
|
1985
|
+
workflow.approval.status !== 'executed' ||
|
|
1986
|
+
workflow.lifecycle.status !== 'ready' ||
|
|
1987
|
+
workflow.cleanupState !== 'ready' ||
|
|
1988
|
+
artifact?.status !== 'available' ||
|
|
1989
|
+
!workflow.currentVersionId ||
|
|
1990
|
+
!workflow.artifactRef
|
|
1991
|
+
) {
|
|
1992
|
+
throw new Error('The export artifact is not ready for delivery.');
|
|
1993
|
+
}
|
|
1994
|
+
if (Date.parse(artifact.expiresAt) <= Date.parse(now)) {
|
|
1995
|
+
throw new Error('The export artifact has expired.');
|
|
1996
|
+
}
|
|
1997
|
+
const record = await client.query(
|
|
1998
|
+
\`SELECT 1
|
|
1999
|
+
FROM records
|
|
2000
|
+
WHERE id = $1::uuid AND owner_id = $2
|
|
2001
|
+
FOR UPDATE\`,
|
|
2002
|
+
[input.recordId, input.principal.actor.id],
|
|
2003
|
+
);
|
|
2004
|
+
if (record.rowCount !== 1) {
|
|
2005
|
+
throw new Error('The export target was not found.');
|
|
2006
|
+
}
|
|
2007
|
+
const holds = await loadActiveRecordHolds(
|
|
2008
|
+
client,
|
|
2009
|
+
input.recordId,
|
|
2010
|
+
now,
|
|
2011
|
+
'export',
|
|
2012
|
+
);
|
|
2013
|
+
const holdEvaluation = evaluateDataLifecycleHolds({
|
|
2014
|
+
categoryId: 'product-record',
|
|
2015
|
+
holds,
|
|
2016
|
+
now,
|
|
2017
|
+
operation: 'export',
|
|
2018
|
+
scopeRef: productRecordReference(input.recordId),
|
|
2019
|
+
});
|
|
2020
|
+
if (holdEvaluation.blocked) {
|
|
2021
|
+
throw new Error('An active record hold blocks export delivery.');
|
|
2022
|
+
}
|
|
2023
|
+
const object = await s3.send(
|
|
2024
|
+
new GetObjectCommand({
|
|
2025
|
+
Bucket: resources.bucketName,
|
|
2026
|
+
Key: workflow.objectKey,
|
|
2027
|
+
VersionId: workflow.currentVersionId,
|
|
2028
|
+
}),
|
|
2029
|
+
);
|
|
2030
|
+
if (
|
|
2031
|
+
object.ContentLength === undefined ||
|
|
2032
|
+
object.ContentLength > 64 * 1_024 ||
|
|
2033
|
+
object.ContentType !== 'application/json' ||
|
|
2034
|
+
!object.Body
|
|
2035
|
+
) {
|
|
2036
|
+
throw new Error('The confidential export artifact is invalid.');
|
|
2037
|
+
}
|
|
2038
|
+
const deliveredRecord = parseDeliveredRecordExport(
|
|
2039
|
+
await object.Body.transformToString(),
|
|
2040
|
+
input.recordId,
|
|
2041
|
+
);
|
|
2042
|
+
await writeDataRightsAudit(client, {
|
|
2043
|
+
action: 'export',
|
|
2044
|
+
approvalRequestId: input.approvalRequestId,
|
|
2045
|
+
correlationId: input.correlationId,
|
|
2046
|
+
now,
|
|
2047
|
+
outcome: 'succeeded',
|
|
2048
|
+
principal: input.principal,
|
|
2049
|
+
purposeCode: 'export-download',
|
|
2050
|
+
recordId: input.recordId,
|
|
2051
|
+
summary:
|
|
2052
|
+
'An authorized subject downloaded an unexpired confidential export.',
|
|
2053
|
+
});
|
|
2054
|
+
await client.query('COMMIT');
|
|
2055
|
+
return {
|
|
2056
|
+
artifactRef: workflow.artifactRef,
|
|
2057
|
+
deliveredAt: now,
|
|
2058
|
+
expiresAt: artifact.expiresAt,
|
|
2059
|
+
record: deliveredRecord,
|
|
2060
|
+
};
|
|
2061
|
+
} catch (error) {
|
|
2062
|
+
await client.query('ROLLBACK').catch(() => undefined);
|
|
2063
|
+
throw error;
|
|
2064
|
+
}
|
|
2065
|
+
},
|
|
2066
|
+
);
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
export interface ExportObjectVersion {
|
|
2070
|
+
isDeleteMarker: boolean;
|
|
2071
|
+
key: string;
|
|
2072
|
+
versionId: string;
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
async function listExactExportVersions(
|
|
2076
|
+
objectKey: string,
|
|
2077
|
+
objectStore: RecordExportObjectStore,
|
|
2078
|
+
): Promise<readonly ExportObjectVersion[]> {
|
|
2079
|
+
const { bucketName } = exportResources();
|
|
2080
|
+
const versions: ExportObjectVersion[] = [];
|
|
2081
|
+
let keyMarker: string | undefined;
|
|
2082
|
+
let versionIdMarker: string | undefined;
|
|
2083
|
+
do {
|
|
2084
|
+
const page = (await objectStore.send(
|
|
2085
|
+
new ListObjectVersionsCommand({
|
|
2086
|
+
Bucket: bucketName,
|
|
2087
|
+
KeyMarker: keyMarker,
|
|
2088
|
+
Prefix: objectKey,
|
|
2089
|
+
VersionIdMarker: versionIdMarker,
|
|
2090
|
+
}),
|
|
2091
|
+
)) as {
|
|
2092
|
+
DeleteMarkers?: Array<{ Key?: string; VersionId?: string }>;
|
|
2093
|
+
IsTruncated?: boolean;
|
|
2094
|
+
NextKeyMarker?: string;
|
|
2095
|
+
NextVersionIdMarker?: string;
|
|
2096
|
+
Versions?: Array<{ Key?: string; VersionId?: string }>;
|
|
2097
|
+
};
|
|
2098
|
+
for (const version of page.Versions ?? []) {
|
|
2099
|
+
if (version.Key === objectKey && version.VersionId) {
|
|
2100
|
+
versions.push({
|
|
2101
|
+
isDeleteMarker: false,
|
|
2102
|
+
key: objectKey,
|
|
2103
|
+
versionId: version.VersionId,
|
|
2104
|
+
});
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
for (const marker of page.DeleteMarkers ?? []) {
|
|
2108
|
+
if (marker.Key === objectKey && marker.VersionId) {
|
|
2109
|
+
versions.push({
|
|
2110
|
+
isDeleteMarker: true,
|
|
2111
|
+
key: objectKey,
|
|
2112
|
+
versionId: marker.VersionId,
|
|
2113
|
+
});
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
keyMarker = page.IsTruncated ? page.NextKeyMarker : undefined;
|
|
2117
|
+
versionIdMarker = page.IsTruncated
|
|
2118
|
+
? page.NextVersionIdMarker
|
|
2119
|
+
: undefined;
|
|
2120
|
+
} while (keyMarker || versionIdMarker);
|
|
2121
|
+
return versions;
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
async function deleteExactExportVersions(
|
|
2125
|
+
versions: readonly ExportObjectVersion[],
|
|
2126
|
+
objectStore: RecordExportObjectStore,
|
|
2127
|
+
): Promise<ReadonlySet<string>> {
|
|
2128
|
+
const { bucketName } = exportResources();
|
|
2129
|
+
const failed = new Set<string>();
|
|
2130
|
+
for (let offset = 0; offset < versions.length; offset += 1_000) {
|
|
2131
|
+
const batch = versions.slice(offset, offset + 1_000);
|
|
2132
|
+
const batchVersionIds = new Set(batch.map(({ versionId }) => versionId));
|
|
2133
|
+
const result = (await objectStore.send(
|
|
2134
|
+
new DeleteObjectsCommand({
|
|
2135
|
+
Bucket: bucketName,
|
|
2136
|
+
Delete: {
|
|
2137
|
+
Objects: batch.map(({ key, versionId }) => ({
|
|
2138
|
+
Key: key,
|
|
2139
|
+
VersionId: versionId,
|
|
2140
|
+
})),
|
|
2141
|
+
Quiet: true,
|
|
2142
|
+
},
|
|
2143
|
+
}),
|
|
2144
|
+
)) as {
|
|
2145
|
+
Errors?: Array<{ Key?: string; VersionId?: string }>;
|
|
2146
|
+
};
|
|
2147
|
+
for (const error of result.Errors ?? []) {
|
|
2148
|
+
if (error.VersionId && batchVersionIds.has(error.VersionId)) {
|
|
2149
|
+
failed.add(error.VersionId);
|
|
2150
|
+
} else {
|
|
2151
|
+
// S3 does not require DeleteError.VersionId. An unmatched or
|
|
2152
|
+
// identifier-free error cannot be attributed safely when every
|
|
2153
|
+
// version shares one key, so fail the whole submitted batch closed.
|
|
2154
|
+
for (const { versionId } of batch) failed.add(versionId);
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
return failed;
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
function mergeExportVersions(
|
|
2162
|
+
...collections: ReadonlyArray<readonly ExportObjectVersion[]>
|
|
2163
|
+
): readonly ExportObjectVersion[] {
|
|
2164
|
+
const versions = new Map<string, ExportObjectVersion>();
|
|
2165
|
+
for (const collection of collections) {
|
|
2166
|
+
for (const version of collection) {
|
|
2167
|
+
versions.set(version.versionId, version);
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
return [...versions.values()].sort((left, right) =>
|
|
2171
|
+
left.versionId < right.versionId
|
|
2172
|
+
? -1
|
|
2173
|
+
: left.versionId > right.versionId
|
|
2174
|
+
? 1
|
|
2175
|
+
: 0,
|
|
2176
|
+
);
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
export interface ExportObjectCleanupResult {
|
|
2180
|
+
attemptedVersions: readonly ExportObjectVersion[];
|
|
2181
|
+
failedVersionIds: readonly string[];
|
|
2182
|
+
remainingVersions: readonly ExportObjectVersion[];
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
/**
|
|
2186
|
+
* Runs one idempotent exact-version cleanup attempt. Fresh S3 discovery is
|
|
2187
|
+
* durably recorded before deletion, while durable unfinished inventory is
|
|
2188
|
+
* retried even when a prior successful S3 delete was followed by database
|
|
2189
|
+
* rollback. A second exact listing is the completion proof.
|
|
2190
|
+
*/
|
|
2191
|
+
export async function runExportObjectCleanupAttempt<Result>(input: {
|
|
2192
|
+
finalize(result: ExportObjectCleanupResult): Promise<Result>;
|
|
2193
|
+
objectKey: string;
|
|
2194
|
+
objectStore?: RecordExportObjectStore;
|
|
2195
|
+
recordDiscovered(
|
|
2196
|
+
versions: readonly ExportObjectVersion[],
|
|
2197
|
+
): Promise<void>;
|
|
2198
|
+
trackedVersions: readonly ExportObjectVersion[];
|
|
2199
|
+
}): Promise<Result> {
|
|
2200
|
+
const objectStore = input.objectStore ?? defaultRecordExportObjectStore;
|
|
2201
|
+
const discovered = await listExactExportVersions(
|
|
2202
|
+
input.objectKey,
|
|
2203
|
+
objectStore,
|
|
2204
|
+
);
|
|
2205
|
+
await input.recordDiscovered(discovered);
|
|
2206
|
+
const attemptedVersions = mergeExportVersions(
|
|
2207
|
+
input.trackedVersions,
|
|
2208
|
+
discovered,
|
|
2209
|
+
);
|
|
2210
|
+
const failed = new Set(
|
|
2211
|
+
await deleteExactExportVersions(attemptedVersions, objectStore),
|
|
2212
|
+
);
|
|
2213
|
+
const remainingVersions = await listExactExportVersions(
|
|
2214
|
+
input.objectKey,
|
|
2215
|
+
objectStore,
|
|
2216
|
+
);
|
|
2217
|
+
await input.recordDiscovered(remainingVersions);
|
|
2218
|
+
for (const { versionId } of remainingVersions) failed.add(versionId);
|
|
2219
|
+
return input.finalize({
|
|
2220
|
+
attemptedVersions: mergeExportVersions(
|
|
2221
|
+
attemptedVersions,
|
|
2222
|
+
remainingVersions,
|
|
2223
|
+
),
|
|
2224
|
+
failedVersionIds: [...failed].sort(),
|
|
2225
|
+
remainingVersions,
|
|
2226
|
+
});
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
async function loadTrackedExportVersions(
|
|
2230
|
+
client: PgClient,
|
|
2231
|
+
approvalRequestId: string,
|
|
2232
|
+
): Promise<readonly ExportObjectVersion[]> {
|
|
2233
|
+
const result = await client.query<{
|
|
2234
|
+
isDeleteMarker: boolean;
|
|
2235
|
+
key: string;
|
|
2236
|
+
versionId: string;
|
|
2237
|
+
}>(
|
|
2238
|
+
\`SELECT is_delete_marker AS "isDeleteMarker",
|
|
2239
|
+
object_key AS key,
|
|
2240
|
+
version_id AS "versionId"
|
|
2241
|
+
FROM data_lifecycle_export_versions
|
|
2242
|
+
WHERE approval_request_id = $1::uuid
|
|
2243
|
+
AND status <> 'deleted'
|
|
2244
|
+
ORDER BY version_id\`,
|
|
2245
|
+
[approvalRequestId],
|
|
2246
|
+
);
|
|
2247
|
+
return result.rows;
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
async function recordDiscoveredExportVersions(
|
|
2251
|
+
client: PgClient,
|
|
2252
|
+
approvalRequestId: string,
|
|
2253
|
+
versions: readonly ExportObjectVersion[],
|
|
2254
|
+
): Promise<void> {
|
|
2255
|
+
for (const version of versions) {
|
|
2256
|
+
await client.query(
|
|
2257
|
+
\`INSERT INTO data_lifecycle_export_versions
|
|
2258
|
+
(approval_request_id, is_delete_marker, object_key, status,
|
|
2259
|
+
version_id)
|
|
2260
|
+
VALUES ($1::uuid, $2, $3, 'deletion-in-progress', $4)
|
|
2261
|
+
ON CONFLICT (approval_request_id, version_id)
|
|
2262
|
+
DO UPDATE SET is_delete_marker = EXCLUDED.is_delete_marker,
|
|
2263
|
+
object_key = EXCLUDED.object_key,
|
|
2264
|
+
status = 'deletion-in-progress'\`,
|
|
2265
|
+
[
|
|
2266
|
+
approvalRequestId,
|
|
2267
|
+
version.isDeleteMarker,
|
|
2268
|
+
version.key,
|
|
2269
|
+
version.versionId,
|
|
2270
|
+
],
|
|
2271
|
+
);
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
async function recordExportCleanupDeadlineReport(
|
|
2276
|
+
client: PgClient,
|
|
2277
|
+
input: {
|
|
2278
|
+
approvalRequestId: string;
|
|
2279
|
+
cleanupDeadlineAt: string;
|
|
2280
|
+
observedAt: string;
|
|
2281
|
+
},
|
|
2282
|
+
): Promise<void> {
|
|
2283
|
+
if (Date.parse(input.cleanupDeadlineAt) > Date.parse(input.observedAt)) {
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
2286
|
+
const capacity = await client.query<{ maximumPending: number }>(
|
|
2287
|
+
\`SELECT maximum_pending AS "maximumPending"
|
|
2288
|
+
FROM cleanup_deadline_report_capacity
|
|
2289
|
+
WHERE feature = $1
|
|
2290
|
+
FOR UPDATE\`,
|
|
2291
|
+
[CONFIDENTIAL_EXPORT_CLEANUP_FEATURE],
|
|
2292
|
+
);
|
|
2293
|
+
if (
|
|
2294
|
+
capacity.rowCount !== 1 ||
|
|
2295
|
+
capacity.rows[0]?.maximumPending !==
|
|
2296
|
+
MAXIMUM_PENDING_EXPORT_CLEANUP_REPORTS
|
|
2297
|
+
) {
|
|
2298
|
+
throw new Error(
|
|
2299
|
+
'Confidential-export cleanup deadline report capacity is invalid.',
|
|
2300
|
+
);
|
|
2301
|
+
}
|
|
2302
|
+
const inserted = await client.query(
|
|
2303
|
+
\`INSERT INTO cleanup_deadline_reports
|
|
2304
|
+
(deadline_at, feature, resource_ref)
|
|
2305
|
+
SELECT $1, $2, $3
|
|
2306
|
+
WHERE (
|
|
2307
|
+
SELECT count(*)
|
|
2308
|
+
FROM (
|
|
2309
|
+
SELECT 1
|
|
2310
|
+
FROM cleanup_deadline_reports
|
|
2311
|
+
WHERE feature = $2
|
|
2312
|
+
LIMIT $4
|
|
2313
|
+
) AS bounded_pending_reports
|
|
2314
|
+
) < $4
|
|
2315
|
+
ON CONFLICT (feature, resource_ref) DO NOTHING\`,
|
|
2316
|
+
[
|
|
2317
|
+
input.cleanupDeadlineAt,
|
|
2318
|
+
CONFIDENTIAL_EXPORT_CLEANUP_FEATURE,
|
|
2319
|
+
input.approvalRequestId,
|
|
2320
|
+
MAXIMUM_PENDING_EXPORT_CLEANUP_REPORTS,
|
|
2321
|
+
],
|
|
2322
|
+
);
|
|
2323
|
+
const persistedDeadline = await client.query<{ deadlineAt: Date }>(
|
|
2324
|
+
\`SELECT deadline_at AS "deadlineAt"
|
|
2325
|
+
FROM cleanup_deadline_reports
|
|
2326
|
+
WHERE feature = $1 AND resource_ref = $2
|
|
2327
|
+
FOR UPDATE\`,
|
|
2328
|
+
[CONFIDENTIAL_EXPORT_CLEANUP_FEATURE, input.approvalRequestId],
|
|
2329
|
+
);
|
|
2330
|
+
const persisted = persistedDeadline.rows[0];
|
|
2331
|
+
if (persisted === undefined) {
|
|
2332
|
+
// The reporter acknowledged and deleted this row concurrently, or the
|
|
2333
|
+
// bounded inventory is saturated. Retained deletion authority must not
|
|
2334
|
+
// depend on the observability path; the next scan re-observes an
|
|
2335
|
+
// unresolved breach.
|
|
2336
|
+
if (inserted.rowCount === 1) {
|
|
2337
|
+
throw new Error(
|
|
2338
|
+
'Persisted confidential-export cleanup deadline report is inconsistent.',
|
|
2339
|
+
);
|
|
2340
|
+
}
|
|
2341
|
+
return;
|
|
2342
|
+
}
|
|
2343
|
+
if (persisted.deadlineAt.toISOString() !== input.cleanupDeadlineAt) {
|
|
2344
|
+
throw new Error(
|
|
2345
|
+
'Persisted confidential-export cleanup deadline report is inconsistent.',
|
|
2346
|
+
);
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
export async function recordPreparedExportCleanupDeadlineReport(
|
|
2351
|
+
client: PgClient,
|
|
2352
|
+
input: {
|
|
2353
|
+
approvalRequestId: string;
|
|
2354
|
+
cleanupDeadlineAt: Date | null;
|
|
2355
|
+
expiresAt: Date | null;
|
|
2356
|
+
lifecycleStatus: string;
|
|
2357
|
+
now: string;
|
|
2358
|
+
uploadStartedAt: Date | null;
|
|
2359
|
+
},
|
|
2360
|
+
): Promise<void> {
|
|
2361
|
+
if (input.lifecycleStatus === 'completed') return;
|
|
2362
|
+
if (!input.uploadStartedAt || !input.cleanupDeadlineAt || !input.expiresAt) {
|
|
2363
|
+
throw new Error(
|
|
2364
|
+
'Persisted confidential-export cleanup schedule is incomplete.',
|
|
2365
|
+
);
|
|
2366
|
+
}
|
|
2367
|
+
const exactSchedule = recordExportSchedule(input.now, {
|
|
2368
|
+
cleanupDeadlineAt: input.cleanupDeadlineAt.toISOString(),
|
|
2369
|
+
expiresAt: input.expiresAt.toISOString(),
|
|
2370
|
+
uploadStartedAt: input.uploadStartedAt.toISOString(),
|
|
2371
|
+
});
|
|
2372
|
+
await recordExportCleanupDeadlineReport(client, {
|
|
2373
|
+
approvalRequestId: input.approvalRequestId,
|
|
2374
|
+
cleanupDeadlineAt: exactSchedule.cleanupDeadlineAt,
|
|
2375
|
+
observedAt: input.now,
|
|
2376
|
+
});
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
export async function recordFinalizedExportCleanupDeadlineReport(
|
|
2380
|
+
client: PgClient,
|
|
2381
|
+
input: {
|
|
2382
|
+
approvalRequestId: string;
|
|
2383
|
+
cleanupDeadlineAt: Date | null;
|
|
2384
|
+
expiresAt: Date | null;
|
|
2385
|
+
now: string;
|
|
2386
|
+
uploadStartedAt: Date | null;
|
|
2387
|
+
},
|
|
2388
|
+
): Promise<void> {
|
|
2389
|
+
if (!input.uploadStartedAt || !input.cleanupDeadlineAt || !input.expiresAt) {
|
|
2390
|
+
throw new Error(
|
|
2391
|
+
'Persisted confidential-export cleanup schedule is incomplete.',
|
|
2392
|
+
);
|
|
2393
|
+
}
|
|
2394
|
+
const exactSchedule = recordExportSchedule(input.now, {
|
|
2395
|
+
cleanupDeadlineAt: input.cleanupDeadlineAt.toISOString(),
|
|
2396
|
+
expiresAt: input.expiresAt.toISOString(),
|
|
2397
|
+
uploadStartedAt: input.uploadStartedAt.toISOString(),
|
|
2398
|
+
});
|
|
2399
|
+
const databaseClock = await client.query<{ observedAt: Date }>(
|
|
2400
|
+
\`SELECT clock_timestamp() AS "observedAt"\`,
|
|
2401
|
+
);
|
|
2402
|
+
const observedAt = databaseClock.rows[0]?.observedAt;
|
|
2403
|
+
if (!observedAt) {
|
|
2404
|
+
throw new Error(
|
|
2405
|
+
'Confidential export cleanup database clock is unavailable.',
|
|
2406
|
+
);
|
|
2407
|
+
}
|
|
2408
|
+
await recordExportCleanupDeadlineReport(client, {
|
|
2409
|
+
approvalRequestId: input.approvalRequestId,
|
|
2410
|
+
cleanupDeadlineAt: exactSchedule.cleanupDeadlineAt,
|
|
2411
|
+
observedAt: observedAt.toISOString(),
|
|
2412
|
+
});
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
export async function cleanupExpiredRecordExport(input: {
|
|
2416
|
+
approvalRequestId: string;
|
|
2417
|
+
correlationId: string;
|
|
2418
|
+
now?: string;
|
|
2419
|
+
principal: AuthorizationPrincipal;
|
|
2420
|
+
recordId?: string;
|
|
2421
|
+
recordReference?: string;
|
|
2422
|
+
}): Promise<{ deletedVersions: number; status: 'completed' | 'retry-required' }> {
|
|
2423
|
+
requireExportCleanupAuthority(input.principal);
|
|
2424
|
+
const now = canonicalDataRightsNow(input.now);
|
|
2425
|
+
if ((input.recordId === undefined) === (input.recordReference === undefined)) {
|
|
2426
|
+
throw new Error(
|
|
2427
|
+
'Export cleanup requires exactly one record identifier or reference.',
|
|
2428
|
+
);
|
|
2429
|
+
}
|
|
2430
|
+
const expectedRecordRef =
|
|
2431
|
+
input.recordReference ?? productRecordReference(input.recordId!);
|
|
2432
|
+
const databaseWorkload =
|
|
2433
|
+
input.principal.kind === 'agent'
|
|
2434
|
+
? ('data-export-cleanup' as const)
|
|
2435
|
+
: ('api-database-records' as const);
|
|
2436
|
+
const prepared = await withPrimaryDatabase(
|
|
2437
|
+
{ correlationId: input.correlationId },
|
|
2438
|
+
databaseWorkload,
|
|
2439
|
+
async (client) => {
|
|
2440
|
+
await client.query('BEGIN');
|
|
2441
|
+
try {
|
|
2442
|
+
const workflow = await loadExportWorkflow(
|
|
2443
|
+
client,
|
|
2444
|
+
input.approvalRequestId,
|
|
2445
|
+
);
|
|
2446
|
+
if (
|
|
2447
|
+
!workflow ||
|
|
2448
|
+
workflow.recordRef !== expectedRecordRef
|
|
2449
|
+
) {
|
|
2450
|
+
throw new Error('The export artifact was not found.');
|
|
2451
|
+
}
|
|
2452
|
+
if (workflow.lifecycle.status === 'completed') {
|
|
2453
|
+
await client.query('COMMIT');
|
|
2454
|
+
return {
|
|
2455
|
+
alreadyCompleted: true as const,
|
|
2456
|
+
objectKey: workflow.objectKey,
|
|
2457
|
+
};
|
|
2458
|
+
}
|
|
2459
|
+
if (
|
|
2460
|
+
!workflow.expiresAt ||
|
|
2461
|
+
Date.parse(workflow.expiresAt.toISOString()) > Date.parse(now)
|
|
2462
|
+
) {
|
|
2463
|
+
throw new Error('The export artifact is not due for cleanup.');
|
|
2464
|
+
}
|
|
2465
|
+
await recordPreparedExportCleanupDeadlineReport(client, {
|
|
2466
|
+
approvalRequestId: input.approvalRequestId,
|
|
2467
|
+
cleanupDeadlineAt: workflow.cleanupDeadlineAt,
|
|
2468
|
+
expiresAt: workflow.expiresAt,
|
|
2469
|
+
lifecycleStatus: workflow.lifecycle.status,
|
|
2470
|
+
now,
|
|
2471
|
+
uploadStartedAt: workflow.uploadStartedAt,
|
|
2472
|
+
});
|
|
2473
|
+
let lifecycle = workflow.lifecycle;
|
|
2474
|
+
if (lifecycle.status === 'ready') {
|
|
2475
|
+
lifecycle = transitionDataLifecycleExport(lifecycle, {
|
|
2476
|
+
at: now,
|
|
2477
|
+
type: 'expire',
|
|
2478
|
+
});
|
|
2479
|
+
lifecycle = transitionDataLifecycleExport(lifecycle, {
|
|
2480
|
+
at: now,
|
|
2481
|
+
type: 'start-artifact-deletion',
|
|
2482
|
+
});
|
|
2483
|
+
} else if (
|
|
2484
|
+
lifecycle.status === 'failed' &&
|
|
2485
|
+
(lifecycle.failurePhase === 'artifact-deletion' ||
|
|
2486
|
+
lifecycle.failurePhase ===
|
|
2487
|
+
'unpublished-artifact-deletion')
|
|
2488
|
+
) {
|
|
2489
|
+
lifecycle = transitionDataLifecycleExport(lifecycle, {
|
|
2490
|
+
at: now,
|
|
2491
|
+
type: 'retry',
|
|
2492
|
+
});
|
|
2493
|
+
} else if (lifecycle.status === 'gathering') {
|
|
2494
|
+
lifecycle = transitionDataLifecycleExport(lifecycle, {
|
|
2495
|
+
at: now,
|
|
2496
|
+
type: 'start-unpublished-artifact-deletion',
|
|
2497
|
+
});
|
|
2498
|
+
} else if (
|
|
2499
|
+
lifecycle.status !== 'artifact-deletion-in-progress' &&
|
|
2500
|
+
lifecycle.status !==
|
|
2501
|
+
'unpublished-artifact-deletion-in-progress'
|
|
2502
|
+
) {
|
|
2503
|
+
throw new Error('The export artifact is not due for cleanup.');
|
|
2504
|
+
}
|
|
2505
|
+
await persistExportState(
|
|
2506
|
+
client,
|
|
2507
|
+
input.approvalRequestId,
|
|
2508
|
+
lifecycle,
|
|
2509
|
+
);
|
|
2510
|
+
await client.query(
|
|
2511
|
+
\`UPDATE data_lifecycle_export_artifacts
|
|
2512
|
+
SET cleanup_state = 'deletion-in-progress', updated_at = $2
|
|
2513
|
+
WHERE approval_request_id = $1::uuid\`,
|
|
2514
|
+
[input.approvalRequestId, now],
|
|
2515
|
+
);
|
|
2516
|
+
await client.query('COMMIT');
|
|
2517
|
+
return {
|
|
2518
|
+
alreadyCompleted: false as const,
|
|
2519
|
+
objectKey: workflow.objectKey,
|
|
2520
|
+
unpublished: lifecycle.artifact === undefined,
|
|
2521
|
+
};
|
|
2522
|
+
} catch (error) {
|
|
2523
|
+
await client.query('ROLLBACK').catch(() => undefined);
|
|
2524
|
+
throw error;
|
|
2525
|
+
}
|
|
2526
|
+
},
|
|
2527
|
+
);
|
|
2528
|
+
if (prepared.alreadyCompleted) {
|
|
2529
|
+
return { deletedVersions: 0, status: 'completed' };
|
|
2530
|
+
}
|
|
2531
|
+
const trackedVersions = await withPrimaryDatabase(
|
|
2532
|
+
{ correlationId: input.correlationId },
|
|
2533
|
+
databaseWorkload,
|
|
2534
|
+
(client) =>
|
|
2535
|
+
loadTrackedExportVersions(client, input.approvalRequestId),
|
|
2536
|
+
);
|
|
2537
|
+
return runExportObjectCleanupAttempt({
|
|
2538
|
+
objectKey: prepared.objectKey,
|
|
2539
|
+
trackedVersions,
|
|
2540
|
+
recordDiscovered: (versions) =>
|
|
2541
|
+
withPrimaryDatabase(
|
|
2542
|
+
{ correlationId: input.correlationId },
|
|
2543
|
+
databaseWorkload,
|
|
2544
|
+
async (client) => {
|
|
2545
|
+
await client.query('BEGIN');
|
|
2546
|
+
try {
|
|
2547
|
+
await recordDiscoveredExportVersions(
|
|
2548
|
+
client,
|
|
2549
|
+
input.approvalRequestId,
|
|
2550
|
+
versions,
|
|
2551
|
+
);
|
|
2552
|
+
await client.query('COMMIT');
|
|
2553
|
+
} catch (error) {
|
|
2554
|
+
await client.query('ROLLBACK').catch(() => undefined);
|
|
2555
|
+
throw error;
|
|
2556
|
+
}
|
|
2557
|
+
},
|
|
2558
|
+
),
|
|
2559
|
+
finalize: ({ attemptedVersions, failedVersionIds }) =>
|
|
2560
|
+
withPrimaryDatabase(
|
|
2561
|
+
{ correlationId: input.correlationId },
|
|
2562
|
+
databaseWorkload,
|
|
2563
|
+
async (client) => {
|
|
2564
|
+
await client.query('BEGIN');
|
|
2565
|
+
try {
|
|
2566
|
+
const workflow = await loadExportWorkflow(
|
|
2567
|
+
client,
|
|
2568
|
+
input.approvalRequestId,
|
|
2569
|
+
);
|
|
2570
|
+
if (!workflow) {
|
|
2571
|
+
throw new Error('The export workflow was not found.');
|
|
2572
|
+
}
|
|
2573
|
+
if (workflow.lifecycle.status === 'completed') {
|
|
2574
|
+
await client.query('COMMIT');
|
|
2575
|
+
return { deletedVersions: 0, status: 'completed' as const };
|
|
2576
|
+
}
|
|
2577
|
+
await recordFinalizedExportCleanupDeadlineReport(client, {
|
|
2578
|
+
approvalRequestId: input.approvalRequestId,
|
|
2579
|
+
cleanupDeadlineAt: workflow.cleanupDeadlineAt,
|
|
2580
|
+
expiresAt: workflow.expiresAt,
|
|
2581
|
+
now,
|
|
2582
|
+
uploadStartedAt: workflow.uploadStartedAt,
|
|
2583
|
+
});
|
|
2584
|
+
const failed = new Set(failedVersionIds);
|
|
2585
|
+
for (const version of attemptedVersions) {
|
|
2586
|
+
await client.query(
|
|
2587
|
+
\`UPDATE data_lifecycle_export_versions
|
|
2588
|
+
SET deleted_at = CASE WHEN $3 THEN NULL ELSE $4 END,
|
|
2589
|
+
status = CASE WHEN $3 THEN 'failed' ELSE 'deleted' END
|
|
2590
|
+
WHERE approval_request_id = $1::uuid AND version_id = $2\`,
|
|
2591
|
+
[
|
|
2592
|
+
input.approvalRequestId,
|
|
2593
|
+
version.versionId,
|
|
2594
|
+
failed.has(version.versionId),
|
|
2595
|
+
now,
|
|
2596
|
+
],
|
|
2597
|
+
);
|
|
2598
|
+
}
|
|
2599
|
+
let lifecycle = workflow.lifecycle;
|
|
2600
|
+
const unpublished = lifecycle.artifact === undefined;
|
|
2601
|
+
if (
|
|
2602
|
+
lifecycle.status === 'failed' &&
|
|
2603
|
+
(lifecycle.failurePhase === 'artifact-deletion' ||
|
|
2604
|
+
lifecycle.failurePhase ===
|
|
2605
|
+
'unpublished-artifact-deletion')
|
|
2606
|
+
) {
|
|
2607
|
+
lifecycle = transitionDataLifecycleExport(lifecycle, {
|
|
2608
|
+
at: now,
|
|
2609
|
+
type: 'retry',
|
|
2610
|
+
});
|
|
2611
|
+
}
|
|
2612
|
+
if (
|
|
2613
|
+
lifecycle.status !== 'artifact-deletion-in-progress' &&
|
|
2614
|
+
lifecycle.status !==
|
|
2615
|
+
'unpublished-artifact-deletion-in-progress'
|
|
2616
|
+
) {
|
|
2617
|
+
throw new Error('The export cleanup attempt changed.');
|
|
2618
|
+
}
|
|
2619
|
+
if (failed.size > 0) {
|
|
2620
|
+
lifecycle = transitionDataLifecycleExport(lifecycle, {
|
|
2621
|
+
at: now,
|
|
2622
|
+
errorCode: 'object-version-delete-failed',
|
|
2623
|
+
type: 'fail-artifact-deletion',
|
|
2624
|
+
});
|
|
2625
|
+
} else {
|
|
2626
|
+
lifecycle = transitionDataLifecycleExport(lifecycle, {
|
|
2627
|
+
at: now,
|
|
2628
|
+
outcomeCode:
|
|
2629
|
+
attemptedVersions.length === 0 ? 'not-found' : 'deleted',
|
|
2630
|
+
type: 'complete-artifact-deletion',
|
|
2631
|
+
});
|
|
2632
|
+
}
|
|
2633
|
+
await persistExportState(
|
|
2634
|
+
client,
|
|
2635
|
+
input.approvalRequestId,
|
|
2636
|
+
lifecycle,
|
|
2637
|
+
);
|
|
2638
|
+
if (failed.size === 0 && unpublished) {
|
|
2639
|
+
const completed = completeApprovalExecution(
|
|
2640
|
+
workflow.approval,
|
|
2641
|
+
{
|
|
2642
|
+
executionId: 'export:' + input.approvalRequestId,
|
|
2643
|
+
now,
|
|
2644
|
+
requiredPolicyVersion: APPROVAL_POLICY_VERSION,
|
|
2645
|
+
},
|
|
2646
|
+
);
|
|
2647
|
+
assertAuthorizationAllowed(completed.decision);
|
|
2648
|
+
await persistApproval(
|
|
2649
|
+
client,
|
|
2650
|
+
workflow.approval.revision,
|
|
2651
|
+
completed.request,
|
|
2652
|
+
);
|
|
2653
|
+
}
|
|
2654
|
+
await client.query(
|
|
2655
|
+
\`UPDATE data_lifecycle_export_artifacts
|
|
2656
|
+
SET cleanup_state = $2,
|
|
2657
|
+
last_error_code = $3,
|
|
2658
|
+
updated_at = $4
|
|
2659
|
+
WHERE approval_request_id = $1::uuid\`,
|
|
2660
|
+
[
|
|
2661
|
+
input.approvalRequestId,
|
|
2662
|
+
failed.size > 0 ? 'failed' : 'completed',
|
|
2663
|
+
failed.size > 0
|
|
2664
|
+
? 'object-version-delete-failed'
|
|
2665
|
+
: null,
|
|
2666
|
+
now,
|
|
2667
|
+
],
|
|
2668
|
+
);
|
|
2669
|
+
await writeDataRightsAudit(client, {
|
|
2670
|
+
action: 'export',
|
|
2671
|
+
approvalRequestId: input.approvalRequestId,
|
|
2672
|
+
correlationId: input.correlationId,
|
|
2673
|
+
now,
|
|
2674
|
+
outcome:
|
|
2675
|
+
failed.size > 0 ? 'retry-required' : 'succeeded',
|
|
2676
|
+
principal: input.principal,
|
|
2677
|
+
purposeCode: 'export-expiry',
|
|
2678
|
+
...(input.recordId
|
|
2679
|
+
? { recordId: input.recordId }
|
|
2680
|
+
: { resourceReference: expectedRecordRef }),
|
|
2681
|
+
summary:
|
|
2682
|
+
failed.size > 0
|
|
2683
|
+
? 'Confidential export version cleanup requires retry.'
|
|
2684
|
+
: 'Every discovered confidential export version was deleted.',
|
|
2685
|
+
});
|
|
2686
|
+
await client.query('COMMIT');
|
|
2687
|
+
return {
|
|
2688
|
+
deletedVersions: attemptedVersions.length - failed.size,
|
|
2689
|
+
status:
|
|
2690
|
+
failed.size > 0
|
|
2691
|
+
? ('retry-required' as const)
|
|
2692
|
+
: ('completed' as const),
|
|
2693
|
+
};
|
|
2694
|
+
} catch (error) {
|
|
2695
|
+
await client.query('ROLLBACK').catch(() => undefined);
|
|
2696
|
+
throw error;
|
|
2697
|
+
}
|
|
2698
|
+
},
|
|
2699
|
+
),
|
|
2700
|
+
});
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
export interface RecordExportCleanupDependencies {
|
|
2704
|
+
cleanupExpiredRecordExport: typeof cleanupExpiredRecordExport;
|
|
2705
|
+
withDatabase: typeof withPrimaryDatabase;
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
export async function reportExportCleanupDeadlineBreaches(
|
|
2709
|
+
input: {
|
|
2710
|
+
correlationId: string;
|
|
2711
|
+
pageSize?: number;
|
|
2712
|
+
report(value: number): boolean;
|
|
2713
|
+
},
|
|
2714
|
+
dependencies: Pick<RecordExportCleanupDependencies, 'withDatabase'> = {
|
|
2715
|
+
withDatabase: withPrimaryDatabase,
|
|
2716
|
+
},
|
|
2717
|
+
): Promise<{ continuationRequired: boolean; reported: number }> {
|
|
2718
|
+
const pageSize =
|
|
2719
|
+
input.pageSize ?? DEFAULT_EXPORT_CLEANUP_REPORT_PAGE_SIZE;
|
|
2720
|
+
if (!Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 100) {
|
|
2721
|
+
throw new Error(
|
|
2722
|
+
'Export cleanup deadline report page size must be from 1 through 100.',
|
|
2723
|
+
);
|
|
2724
|
+
}
|
|
2725
|
+
return dependencies.withDatabase(
|
|
2726
|
+
{ correlationId: input.correlationId },
|
|
2727
|
+
'data-export-cleanup',
|
|
2728
|
+
async (client) => {
|
|
2729
|
+
await client.query('BEGIN');
|
|
2730
|
+
try {
|
|
2731
|
+
const pending = await client.query<{ resourceRef: string }>(
|
|
2732
|
+
\`SELECT resource_ref AS "resourceRef"
|
|
2733
|
+
FROM cleanup_deadline_reports
|
|
2734
|
+
WHERE feature = $1
|
|
2735
|
+
ORDER BY resource_ref::uuid
|
|
2736
|
+
LIMIT $2
|
|
2737
|
+
FOR UPDATE\`,
|
|
2738
|
+
[CONFIDENTIAL_EXPORT_CLEANUP_FEATURE, pageSize + 1],
|
|
2739
|
+
);
|
|
2740
|
+
const selected = pending.rows.slice(0, pageSize);
|
|
2741
|
+
if (!input.report(selected.length)) {
|
|
2742
|
+
throw new Error(
|
|
2743
|
+
'Confidential export cleanup deadline metric delivery failed.',
|
|
2744
|
+
);
|
|
2745
|
+
}
|
|
2746
|
+
if (selected.length > 0) {
|
|
2747
|
+
await client.query(
|
|
2748
|
+
\`DELETE FROM cleanup_deadline_reports
|
|
2749
|
+
WHERE feature = $1 AND resource_ref = ANY($2::text[])\`,
|
|
2750
|
+
[
|
|
2751
|
+
CONFIDENTIAL_EXPORT_CLEANUP_FEATURE,
|
|
2752
|
+
selected.map(({ resourceRef }) => resourceRef),
|
|
2753
|
+
],
|
|
2754
|
+
);
|
|
2755
|
+
}
|
|
2756
|
+
await client.query('COMMIT');
|
|
2757
|
+
return {
|
|
2758
|
+
continuationRequired: pending.rows.length > pageSize,
|
|
2759
|
+
reported: selected.length,
|
|
2760
|
+
};
|
|
2761
|
+
} catch (error) {
|
|
2762
|
+
await client.query('ROLLBACK').catch(() => undefined);
|
|
2763
|
+
throw error;
|
|
2764
|
+
}
|
|
2765
|
+
},
|
|
2766
|
+
);
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
export async function cleanupDueRecordExports(input: {
|
|
2770
|
+
correlationId: string;
|
|
2771
|
+
limit?: number;
|
|
2772
|
+
maximumBatches?: number;
|
|
2773
|
+
now?: string;
|
|
2774
|
+
principal: AuthorizationPrincipal;
|
|
2775
|
+
shouldContinue?: () => boolean;
|
|
2776
|
+
}, dependencies: Partial<RecordExportCleanupDependencies> = {}): Promise<{
|
|
2777
|
+
batches: number;
|
|
2778
|
+
completed: number;
|
|
2779
|
+
continuationRequired: boolean;
|
|
2780
|
+
deadlineBreached: boolean;
|
|
2781
|
+
processed: number;
|
|
2782
|
+
remainingDeadlineBreaches: number;
|
|
2783
|
+
remainingDue: number;
|
|
2784
|
+
retryRequired: number;
|
|
2785
|
+
}> {
|
|
2786
|
+
requireExportCleanupAuthority(input.principal);
|
|
2787
|
+
const now = canonicalDataRightsNow(input.now);
|
|
2788
|
+
const nowEpochMilliseconds = Date.parse(now);
|
|
2789
|
+
const limit = input.limit ?? DEFAULT_EXPORT_CLEANUP_PAGE_SIZE;
|
|
2790
|
+
const maximumBatches = input.maximumBatches ?? 100;
|
|
2791
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
|
|
2792
|
+
throw new Error('Export cleanup batch size must be from 1 through 100.');
|
|
2793
|
+
}
|
|
2794
|
+
if (
|
|
2795
|
+
!Number.isSafeInteger(maximumBatches) ||
|
|
2796
|
+
maximumBatches < 1 ||
|
|
2797
|
+
maximumBatches > 100
|
|
2798
|
+
) {
|
|
2799
|
+
throw new Error(
|
|
2800
|
+
'Export cleanup batch count must be from 1 through 100.',
|
|
2801
|
+
);
|
|
2802
|
+
}
|
|
2803
|
+
const cleanupExpired =
|
|
2804
|
+
dependencies.cleanupExpiredRecordExport ?? cleanupExpiredRecordExport;
|
|
2805
|
+
const withDatabase = dependencies.withDatabase ?? withPrimaryDatabase;
|
|
2806
|
+
const shouldContinue = input.shouldContinue ?? (() => true);
|
|
2807
|
+
let batches = 0;
|
|
2808
|
+
let completed = 0;
|
|
2809
|
+
let cursorApprovalRequestId: string | null = null;
|
|
2810
|
+
let cursorExpiresAt: string | null = null;
|
|
2811
|
+
let observedDeadlineBreach = await withDatabase(
|
|
2812
|
+
{ correlationId: input.correlationId },
|
|
2813
|
+
'data-export-cleanup',
|
|
2814
|
+
async (client) => {
|
|
2815
|
+
await client.query('BEGIN');
|
|
2816
|
+
try {
|
|
2817
|
+
const activeSchedules = await client.query<{
|
|
2818
|
+
approvalRequestId: string;
|
|
2819
|
+
cleanupDeadlineAt: Date | null;
|
|
2820
|
+
expiresAt: Date | null;
|
|
2821
|
+
uploadStartedAt: Date | null;
|
|
2822
|
+
}>(
|
|
2823
|
+
\`SELECT approval_request_id::text AS "approvalRequestId",
|
|
2824
|
+
cleanup_deadline_at AS "cleanupDeadlineAt",
|
|
2825
|
+
expires_at AS "expiresAt",
|
|
2826
|
+
upload_started_at AS "uploadStartedAt"
|
|
2827
|
+
FROM data_lifecycle_export_artifacts
|
|
2828
|
+
WHERE cleanup_state IN (
|
|
2829
|
+
'gathering',
|
|
2830
|
+
'ready',
|
|
2831
|
+
'deletion-in-progress',
|
|
2832
|
+
'failed'
|
|
2833
|
+
)
|
|
2834
|
+
ORDER BY approval_request_id
|
|
2835
|
+
LIMIT $1
|
|
2836
|
+
FOR UPDATE\`,
|
|
2837
|
+
[MAXIMUM_OUTSTANDING_CONFIDENTIAL_EXPORTS + 1],
|
|
2838
|
+
);
|
|
2839
|
+
if (
|
|
2840
|
+
activeSchedules.rows.length >
|
|
2841
|
+
MAXIMUM_OUTSTANDING_CONFIDENTIAL_EXPORTS
|
|
2842
|
+
) {
|
|
2843
|
+
throw new Error(
|
|
2844
|
+
'The bounded confidential-export cleanup inventory was exceeded.',
|
|
2845
|
+
);
|
|
2846
|
+
}
|
|
2847
|
+
let observed = false;
|
|
2848
|
+
for (const schedule of activeSchedules.rows) {
|
|
2849
|
+
if (
|
|
2850
|
+
schedule.uploadStartedAt === null ||
|
|
2851
|
+
schedule.expiresAt === null ||
|
|
2852
|
+
schedule.cleanupDeadlineAt === null
|
|
2853
|
+
) {
|
|
2854
|
+
throw new Error(
|
|
2855
|
+
'Persisted confidential-export cleanup schedule is incomplete.',
|
|
2856
|
+
);
|
|
2857
|
+
}
|
|
2858
|
+
const exactSchedule = recordExportSchedule(now, {
|
|
2859
|
+
cleanupDeadlineAt: schedule.cleanupDeadlineAt.toISOString(),
|
|
2860
|
+
expiresAt: schedule.expiresAt.toISOString(),
|
|
2861
|
+
uploadStartedAt: schedule.uploadStartedAt.toISOString(),
|
|
2862
|
+
});
|
|
2863
|
+
if (
|
|
2864
|
+
Date.parse(exactSchedule.cleanupDeadlineAt) <=
|
|
2865
|
+
nowEpochMilliseconds
|
|
2866
|
+
) {
|
|
2867
|
+
observed = true;
|
|
2868
|
+
await recordExportCleanupDeadlineReport(client, {
|
|
2869
|
+
approvalRequestId: schedule.approvalRequestId,
|
|
2870
|
+
cleanupDeadlineAt: exactSchedule.cleanupDeadlineAt,
|
|
2871
|
+
observedAt: now,
|
|
2872
|
+
});
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
await client.query('COMMIT');
|
|
2876
|
+
return observed;
|
|
2877
|
+
} catch (error) {
|
|
2878
|
+
await client.query('ROLLBACK').catch(() => undefined);
|
|
2879
|
+
throw error;
|
|
2880
|
+
}
|
|
2881
|
+
},
|
|
2882
|
+
);
|
|
2883
|
+
let processed = 0;
|
|
2884
|
+
let retryRequired = 0;
|
|
2885
|
+
while (batches < maximumBatches && shouldContinue()) {
|
|
2886
|
+
const due = await withDatabase(
|
|
2887
|
+
{ correlationId: input.correlationId },
|
|
2888
|
+
'data-export-cleanup',
|
|
2889
|
+
(client) =>
|
|
2890
|
+
client.query<{
|
|
2891
|
+
approvalRequestId: string;
|
|
2892
|
+
cleanupDeadlineAt: Date | null;
|
|
2893
|
+
expiresAt: Date;
|
|
2894
|
+
recordReference: string;
|
|
2895
|
+
uploadStartedAt: Date | null;
|
|
2896
|
+
}>(
|
|
2897
|
+
\`SELECT approval_request_id::text AS "approvalRequestId",
|
|
2898
|
+
cleanup_deadline_at AS "cleanupDeadlineAt",
|
|
2899
|
+
expires_at AS "expiresAt",
|
|
2900
|
+
record_ref AS "recordReference",
|
|
2901
|
+
upload_started_at AS "uploadStartedAt"
|
|
2902
|
+
FROM data_lifecycle_export_artifacts
|
|
2903
|
+
WHERE cleanup_state IN (
|
|
2904
|
+
'gathering',
|
|
2905
|
+
'ready',
|
|
2906
|
+
'deletion-in-progress',
|
|
2907
|
+
'failed'
|
|
2908
|
+
)
|
|
2909
|
+
AND expires_at IS NOT NULL
|
|
2910
|
+
AND expires_at <= $1
|
|
2911
|
+
AND (
|
|
2912
|
+
$3::timestamptz IS NULL OR
|
|
2913
|
+
(expires_at, approval_request_id) >
|
|
2914
|
+
($3::timestamptz, $4::uuid)
|
|
2915
|
+
)
|
|
2916
|
+
ORDER BY expires_at, approval_request_id
|
|
2917
|
+
LIMIT $2\`,
|
|
2918
|
+
[now, limit, cursorExpiresAt, cursorApprovalRequestId],
|
|
2919
|
+
),
|
|
2920
|
+
);
|
|
2921
|
+
if (due.rows.length === 0) {
|
|
2922
|
+
break;
|
|
2923
|
+
}
|
|
2924
|
+
batches += 1;
|
|
2925
|
+
for (const candidate of due.rows) {
|
|
2926
|
+
if (
|
|
2927
|
+
candidate.uploadStartedAt === null ||
|
|
2928
|
+
candidate.cleanupDeadlineAt === null
|
|
2929
|
+
) {
|
|
2930
|
+
throw new Error(
|
|
2931
|
+
'Persisted confidential-export cleanup schedule is incomplete.',
|
|
2932
|
+
);
|
|
2933
|
+
}
|
|
2934
|
+
const exactSchedule = recordExportSchedule(now, {
|
|
2935
|
+
cleanupDeadlineAt: candidate.cleanupDeadlineAt.toISOString(),
|
|
2936
|
+
expiresAt: candidate.expiresAt.toISOString(),
|
|
2937
|
+
uploadStartedAt: candidate.uploadStartedAt.toISOString(),
|
|
2938
|
+
});
|
|
2939
|
+
processed += 1;
|
|
2940
|
+
if (Date.parse(exactSchedule.cleanupDeadlineAt) <= nowEpochMilliseconds) {
|
|
2941
|
+
observedDeadlineBreach = true;
|
|
2942
|
+
}
|
|
2943
|
+
try {
|
|
2944
|
+
const result = await cleanupExpired({
|
|
2945
|
+
approvalRequestId: candidate.approvalRequestId,
|
|
2946
|
+
correlationId: input.correlationId,
|
|
2947
|
+
now,
|
|
2948
|
+
principal: input.principal,
|
|
2949
|
+
recordReference: candidate.recordReference,
|
|
2950
|
+
});
|
|
2951
|
+
if (result.status === 'completed') completed += 1;
|
|
2952
|
+
else retryRequired += 1;
|
|
2953
|
+
} catch {
|
|
2954
|
+
retryRequired += 1;
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
const cursor = due.rows.at(-1);
|
|
2958
|
+
cursorApprovalRequestId = cursor?.approvalRequestId ?? null;
|
|
2959
|
+
cursorExpiresAt = cursor?.expiresAt.toISOString() ?? null;
|
|
2960
|
+
if (due.rows.length < limit) {
|
|
2961
|
+
break;
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
const finalState = await withDatabase(
|
|
2965
|
+
{ correlationId: input.correlationId },
|
|
2966
|
+
'data-export-cleanup',
|
|
2967
|
+
(client) =>
|
|
2968
|
+
client.query<{
|
|
2969
|
+
remainingDeadlineBreaches: string;
|
|
2970
|
+
remainingDue: string;
|
|
2971
|
+
}>(
|
|
2972
|
+
\`SELECT
|
|
2973
|
+
count(*) FILTER (
|
|
2974
|
+
WHERE expires_at IS NOT NULL AND expires_at <= $1
|
|
2975
|
+
)::text AS "remainingDue",
|
|
2976
|
+
count(*) FILTER (
|
|
2977
|
+
WHERE cleanup_deadline_at IS NULL
|
|
2978
|
+
OR cleanup_deadline_at <= $1
|
|
2979
|
+
)::text AS "remainingDeadlineBreaches"
|
|
2980
|
+
FROM data_lifecycle_export_artifacts
|
|
2981
|
+
WHERE cleanup_state IN (
|
|
2982
|
+
'gathering',
|
|
2983
|
+
'ready',
|
|
2984
|
+
'deletion-in-progress',
|
|
2985
|
+
'failed'
|
|
2986
|
+
)\`,
|
|
2987
|
+
[now],
|
|
2988
|
+
),
|
|
2989
|
+
);
|
|
2990
|
+
const remainingDeadlineBreaches = Number(
|
|
2991
|
+
finalState.rows[0]?.remainingDeadlineBreaches ?? 0,
|
|
2992
|
+
);
|
|
2993
|
+
const remainingDue = Number(finalState.rows[0]?.remainingDue ?? 0);
|
|
2994
|
+
return {
|
|
2995
|
+
batches,
|
|
2996
|
+
completed,
|
|
2997
|
+
continuationRequired: retryRequired > 0 || remainingDue > 0,
|
|
2998
|
+
deadlineBreached:
|
|
2999
|
+
observedDeadlineBreach || remainingDeadlineBreaches > 0,
|
|
3000
|
+
processed,
|
|
3001
|
+
remainingDeadlineBreaches,
|
|
3002
|
+
remainingDue,
|
|
3003
|
+
retryRequired,
|
|
3004
|
+
};
|
|
3005
|
+
}
|
|
3006
|
+
`;
|
|
3007
|
+
}
|
|
3008
|
+
function renderDataRightsDeadlineLockOrderIntegrationSource(name) {
|
|
3009
|
+
return `import type { AuthorizationPrincipal } from '@empire-builder-kit/runtime/authorization';
|
|
3010
|
+
import { Client } from 'pg';
|
|
3011
|
+
import { describe, expect, it } from 'vitest';
|
|
3012
|
+
import {
|
|
3013
|
+
cleanupDueRecordExports,
|
|
3014
|
+
recordPreparedExportCleanupDeadlineReport,
|
|
3015
|
+
reportExportCleanupDeadlineBreaches,
|
|
3016
|
+
} from '../data/record-export.js';
|
|
3017
|
+
|
|
3018
|
+
const databaseUrl = process.env.DATABASE_URL;
|
|
3019
|
+
if (!databaseUrl) {
|
|
3020
|
+
throw new Error(
|
|
3021
|
+
'DATABASE_URL is required for ${name} deadline lock-order proof.',
|
|
3022
|
+
);
|
|
3023
|
+
}
|
|
3024
|
+
|
|
3025
|
+
const cleanupPrincipal: AuthorizationPrincipal = {
|
|
3026
|
+
actor: { id: '${name}:data-export-cleanup', type: 'service' },
|
|
3027
|
+
audience: '${name}:data-export-cleanup',
|
|
3028
|
+
authority: {
|
|
3029
|
+
machineIdentityId: '${name}:data-export-cleanup',
|
|
3030
|
+
mode: 'machine',
|
|
3031
|
+
},
|
|
3032
|
+
kind: 'agent',
|
|
3033
|
+
roles: [],
|
|
3034
|
+
scopes: [],
|
|
3035
|
+
subject: { id: '${name}:data-export-cleanup', type: 'service' },
|
|
3036
|
+
};
|
|
3037
|
+
|
|
3038
|
+
function deferred(): { promise: Promise<void>; resolve(): void } {
|
|
3039
|
+
let resolvePromise = () => undefined;
|
|
3040
|
+
const promise = new Promise<void>((resolve) => {
|
|
3041
|
+
resolvePromise = resolve;
|
|
3042
|
+
});
|
|
3043
|
+
return { promise, resolve: resolvePromise };
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
// Time bounds in this proof are loaded-host budgets: hosted runners share
|
|
3047
|
+
// four cores between Vitest, the proof registry, and the dockerized
|
|
3048
|
+
// database, so idle-host margins intermittently fail under saturation.
|
|
3049
|
+
async function bounded<Result>(
|
|
3050
|
+
promise: Promise<Result>,
|
|
3051
|
+
label: string,
|
|
3052
|
+
timeoutMilliseconds = 15_000,
|
|
3053
|
+
): Promise<Result> {
|
|
3054
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
3055
|
+
try {
|
|
3056
|
+
return await Promise.race([
|
|
3057
|
+
promise,
|
|
3058
|
+
new Promise<never>((_resolve, reject) => {
|
|
3059
|
+
timer = setTimeout(
|
|
3060
|
+
() => reject(new Error(label + ' exceeded its time bound.')),
|
|
3061
|
+
timeoutMilliseconds,
|
|
3062
|
+
);
|
|
3063
|
+
}),
|
|
3064
|
+
]);
|
|
3065
|
+
} finally {
|
|
3066
|
+
if (timer) clearTimeout(timer);
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
async function query(
|
|
3071
|
+
client: Client,
|
|
3072
|
+
text: string,
|
|
3073
|
+
values?: readonly unknown[],
|
|
3074
|
+
) {
|
|
3075
|
+
return values === undefined
|
|
3076
|
+
? client.query(text)
|
|
3077
|
+
: client.query(text, [...values]);
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
async function waitForExpectedBlocker(
|
|
3081
|
+
observer: Client,
|
|
3082
|
+
blockedProcessId: number,
|
|
3083
|
+
blockerProcessId: number,
|
|
3084
|
+
): Promise<void> {
|
|
3085
|
+
const deadline = Date.now() + 10_000;
|
|
3086
|
+
while (Date.now() < deadline) {
|
|
3087
|
+
const result = await observer.query<{ blockedByExpected: boolean }>(
|
|
3088
|
+
\`SELECT $2::integer = ANY(pg_blocking_pids($1))
|
|
3089
|
+
AS "blockedByExpected"\`,
|
|
3090
|
+
[blockedProcessId, blockerProcessId],
|
|
3091
|
+
);
|
|
3092
|
+
if (result.rows[0]?.blockedByExpected === true) return;
|
|
3093
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
3094
|
+
}
|
|
3095
|
+
throw new Error('Backend did not reach the expected database blocker.');
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
function databaseErrorCode(reason: unknown): string | undefined {
|
|
3099
|
+
if (!reason || typeof reason !== 'object' || !('code' in reason)) {
|
|
3100
|
+
return undefined;
|
|
3101
|
+
}
|
|
3102
|
+
return typeof reason.code === 'string' ? reason.code : undefined;
|
|
3103
|
+
}
|
|
3104
|
+
|
|
3105
|
+
describe('${name} confidential-export cleanup deadline lock order', () => {
|
|
3106
|
+
it('settles concurrent cleanup observation and metric acknowledgement in UUID order', async () => {
|
|
3107
|
+
const lowerResourceRef = '00000000-0000-4000-8000-000000000101';
|
|
3108
|
+
const higherResourceRef = '00000000-0000-4000-8000-000000000102';
|
|
3109
|
+
const resourceRefs = [lowerResourceRef, higherResourceRef] as const;
|
|
3110
|
+
const cleanupDeadlineAt = '2026-08-02T12:00:00.000Z';
|
|
3111
|
+
const expiresAt = '2026-08-02T09:00:00.000Z';
|
|
3112
|
+
const uploadStartedAt = '2026-08-01T12:00:00.000Z';
|
|
3113
|
+
const cleanup = new Client({ connectionString: databaseUrl });
|
|
3114
|
+
const reporter = new Client({ connectionString: databaseUrl });
|
|
3115
|
+
const observer = new Client({ connectionString: databaseUrl });
|
|
3116
|
+
try {
|
|
3117
|
+
await Promise.all([
|
|
3118
|
+
cleanup.connect(),
|
|
3119
|
+
reporter.connect(),
|
|
3120
|
+
observer.connect(),
|
|
3121
|
+
]);
|
|
3122
|
+
await cleanup.query("SET lock_timeout = '10s'");
|
|
3123
|
+
// The reporter is deliberately blocked by cleanup. Its query therefore
|
|
3124
|
+
// cannot share the observer's deadline: equal time bounds race under a
|
|
3125
|
+
// loaded scheduler before the observer can prove the blocker identity.
|
|
3126
|
+
await reporter.query("SET lock_timeout = '0'");
|
|
3127
|
+
for (const client of [cleanup, reporter]) {
|
|
3128
|
+
await client.query("SET statement_timeout = '20s'");
|
|
3129
|
+
await client.query("SET deadlock_timeout = '100ms'");
|
|
3130
|
+
}
|
|
3131
|
+
await observer.query(
|
|
3132
|
+
\`DELETE FROM cleanup_deadline_reports
|
|
3133
|
+
WHERE feature = 'confidential-export'
|
|
3134
|
+
AND resource_ref = ANY($1::text[])\`,
|
|
3135
|
+
[resourceRefs],
|
|
3136
|
+
);
|
|
3137
|
+
await observer.query(
|
|
3138
|
+
'DELETE FROM data_lifecycle_export_artifacts WHERE approval_request_id = ANY($1::uuid[])',
|
|
3139
|
+
[resourceRefs],
|
|
3140
|
+
);
|
|
3141
|
+
await observer.query(
|
|
3142
|
+
'DELETE FROM approval_requests WHERE id = ANY($1::uuid[])',
|
|
3143
|
+
[resourceRefs],
|
|
3144
|
+
);
|
|
3145
|
+
for (const resourceRef of resourceRefs) {
|
|
3146
|
+
await observer.query(
|
|
3147
|
+
\`INSERT INTO approval_requests
|
|
3148
|
+
(id, author_reference, expires_at, policy_version,
|
|
3149
|
+
request_digest, request_json, revision, status)
|
|
3150
|
+
VALUES ($1::uuid, 'lock-order-proof', $2, 'lock-order-proof',
|
|
3151
|
+
'lock-order-proof', '{}'::jsonb, 0, 'approved')\`,
|
|
3152
|
+
[resourceRef, cleanupDeadlineAt],
|
|
3153
|
+
);
|
|
3154
|
+
await observer.query(
|
|
3155
|
+
\`INSERT INTO data_lifecycle_export_artifacts
|
|
3156
|
+
(approval_request_id, cleanup_deadline_at, cleanup_state,
|
|
3157
|
+
expires_at, object_key, record_ref, subject_ref,
|
|
3158
|
+
upload_started_at)
|
|
3159
|
+
VALUES ($1::uuid, $2, 'ready', $3, $4, $5, $6, $7)\`,
|
|
3160
|
+
[
|
|
3161
|
+
resourceRef,
|
|
3162
|
+
cleanupDeadlineAt,
|
|
3163
|
+
expiresAt,
|
|
3164
|
+
'exports/' + resourceRef,
|
|
3165
|
+
'opaque:record:' + resourceRef,
|
|
3166
|
+
'opaque:subject:' + resourceRef,
|
|
3167
|
+
uploadStartedAt,
|
|
3168
|
+
],
|
|
3169
|
+
);
|
|
3170
|
+
}
|
|
3171
|
+
await observer.query(
|
|
3172
|
+
\`INSERT INTO cleanup_deadline_reports
|
|
3173
|
+
(deadline_at, feature, observed_at, resource_ref)
|
|
3174
|
+
VALUES
|
|
3175
|
+
($1, 'confidential-export', $2, $3),
|
|
3176
|
+
($1, 'confidential-export', $4, $5)\`,
|
|
3177
|
+
[
|
|
3178
|
+
cleanupDeadlineAt,
|
|
3179
|
+
'2026-08-02T12:00:01.000Z',
|
|
3180
|
+
lowerResourceRef,
|
|
3181
|
+
'2026-08-02T12:00:00.000Z',
|
|
3182
|
+
higherResourceRef,
|
|
3183
|
+
],
|
|
3184
|
+
);
|
|
3185
|
+
const persistedOrder = await observer.query<{
|
|
3186
|
+
resourceRef: string;
|
|
3187
|
+
}>(
|
|
3188
|
+
\`SELECT resource_ref AS "resourceRef"
|
|
3189
|
+
FROM cleanup_deadline_reports
|
|
3190
|
+
WHERE feature = 'confidential-export'
|
|
3191
|
+
AND resource_ref = ANY($1::text[])
|
|
3192
|
+
ORDER BY observed_at, resource_ref\`,
|
|
3193
|
+
[resourceRefs],
|
|
3194
|
+
);
|
|
3195
|
+
expect(persistedOrder.rows.map(({ resourceRef }) => resourceRef)).toEqual([
|
|
3196
|
+
higherResourceRef,
|
|
3197
|
+
lowerResourceRef,
|
|
3198
|
+
]);
|
|
3199
|
+
|
|
3200
|
+
const [cleanupBackend, reporterBackend] = await Promise.all([
|
|
3201
|
+
cleanup.query<{ processId: number }>(
|
|
3202
|
+
'SELECT pg_backend_pid() AS "processId"',
|
|
3203
|
+
),
|
|
3204
|
+
reporter.query<{ processId: number }>(
|
|
3205
|
+
'SELECT pg_backend_pid() AS "processId"',
|
|
3206
|
+
),
|
|
3207
|
+
]);
|
|
3208
|
+
const cleanupProcessId = cleanupBackend.rows[0]?.processId;
|
|
3209
|
+
const reporterProcessId = reporterBackend.rows[0]?.processId;
|
|
3210
|
+
if (!cleanupProcessId || !reporterProcessId) {
|
|
3211
|
+
throw new Error('Database process identity is unavailable.');
|
|
3212
|
+
}
|
|
3213
|
+
const firstCleanupReportLocked = deferred();
|
|
3214
|
+
const reporterWaitingForCleanup = deferred();
|
|
3215
|
+
const cleanupCommitCompleted = deferred();
|
|
3216
|
+
let cleanupReportLocks = 0;
|
|
3217
|
+
let cleanupObservationCommitted = false;
|
|
3218
|
+
const cleanupDatabase = {
|
|
3219
|
+
query: async (text: string, values?: readonly unknown[]) => {
|
|
3220
|
+
const result = await query(cleanup, text, values);
|
|
3221
|
+
if (
|
|
3222
|
+
text.includes('SELECT deadline_at AS "deadlineAt"') &&
|
|
3223
|
+
text.includes('FOR UPDATE')
|
|
3224
|
+
) {
|
|
3225
|
+
cleanupReportLocks += 1;
|
|
3226
|
+
if (cleanupReportLocks === 1) {
|
|
3227
|
+
firstCleanupReportLocked.resolve();
|
|
3228
|
+
await bounded(
|
|
3229
|
+
reporterWaitingForCleanup.promise,
|
|
3230
|
+
'Reporter lock-order rendezvous',
|
|
3231
|
+
);
|
|
3232
|
+
}
|
|
3233
|
+
}
|
|
3234
|
+
if (text === 'COMMIT') {
|
|
3235
|
+
cleanupObservationCommitted = true;
|
|
3236
|
+
cleanupCommitCompleted.resolve();
|
|
3237
|
+
}
|
|
3238
|
+
return result;
|
|
3239
|
+
},
|
|
3240
|
+
};
|
|
3241
|
+
const reporterDatabase = {
|
|
3242
|
+
query: async (text: string, values?: readonly unknown[]) => {
|
|
3243
|
+
if (
|
|
3244
|
+
text.includes('SELECT resource_ref AS "resourceRef"') &&
|
|
3245
|
+
text.includes('FOR UPDATE')
|
|
3246
|
+
) {
|
|
3247
|
+
await bounded(
|
|
3248
|
+
firstCleanupReportLocked.promise,
|
|
3249
|
+
'Cleanup lock-order rendezvous',
|
|
3250
|
+
);
|
|
3251
|
+
const pending = query(reporter, text, values);
|
|
3252
|
+
void pending.catch(() => undefined);
|
|
3253
|
+
await waitForExpectedBlocker(
|
|
3254
|
+
observer,
|
|
3255
|
+
reporterProcessId,
|
|
3256
|
+
cleanupProcessId,
|
|
3257
|
+
);
|
|
3258
|
+
reporterWaitingForCleanup.resolve();
|
|
3259
|
+
const result = await pending;
|
|
3260
|
+
// PostgreSQL releases the row lock before the pg client's COMMIT
|
|
3261
|
+
// continuation is necessarily scheduled. Join that continuation
|
|
3262
|
+
// before the synchronous metric callback observes test state.
|
|
3263
|
+
await bounded(
|
|
3264
|
+
cleanupCommitCompleted.promise,
|
|
3265
|
+
'Cleanup commit observation',
|
|
3266
|
+
);
|
|
3267
|
+
return result;
|
|
3268
|
+
}
|
|
3269
|
+
return query(reporter, text, values);
|
|
3270
|
+
},
|
|
3271
|
+
};
|
|
3272
|
+
const reportedValues: number[] = [];
|
|
3273
|
+
const settled = await bounded(
|
|
3274
|
+
Promise.allSettled([
|
|
3275
|
+
cleanupDueRecordExports(
|
|
3276
|
+
{
|
|
3277
|
+
correlationId: 'cleanup-deadline-lock-order',
|
|
3278
|
+
maximumBatches: 1,
|
|
3279
|
+
now: cleanupDeadlineAt,
|
|
3280
|
+
principal: cleanupPrincipal,
|
|
3281
|
+
shouldContinue: () => false,
|
|
3282
|
+
},
|
|
3283
|
+
{ withDatabase: async (_context, _workload, operation) =>
|
|
3284
|
+
operation(cleanupDatabase as never) },
|
|
3285
|
+
),
|
|
3286
|
+
reportExportCleanupDeadlineBreaches(
|
|
3287
|
+
{
|
|
3288
|
+
correlationId: 'report-deadline-lock-order',
|
|
3289
|
+
report: (value) => {
|
|
3290
|
+
expect(cleanupObservationCommitted).toBe(true);
|
|
3291
|
+
reportedValues.push(value);
|
|
3292
|
+
return true;
|
|
3293
|
+
},
|
|
3294
|
+
},
|
|
3295
|
+
{ withDatabase: async (_context, _workload, operation) =>
|
|
3296
|
+
operation(reporterDatabase as never) },
|
|
3297
|
+
),
|
|
3298
|
+
]),
|
|
3299
|
+
'Concurrent confidential-export deadline operations',
|
|
3300
|
+
30_000,
|
|
3301
|
+
);
|
|
3302
|
+
const rejectionCodes = settled.flatMap((result) =>
|
|
3303
|
+
result.status === 'rejected'
|
|
3304
|
+
? [databaseErrorCode(result.reason)]
|
|
3305
|
+
: [],
|
|
3306
|
+
);
|
|
3307
|
+
expect(rejectionCodes).not.toContain('40P01');
|
|
3308
|
+
expect(rejectionCodes).not.toContain('55P03');
|
|
3309
|
+
expect(settled.map(({ status }) => status)).toEqual([
|
|
3310
|
+
'fulfilled',
|
|
3311
|
+
'fulfilled',
|
|
3312
|
+
]);
|
|
3313
|
+
if (settled[0]?.status !== 'fulfilled') throw settled[0]?.reason;
|
|
3314
|
+
if (settled[1]?.status !== 'fulfilled') throw settled[1]?.reason;
|
|
3315
|
+
expect(settled[0].value).toMatchObject({
|
|
3316
|
+
batches: 0,
|
|
3317
|
+
deadlineBreached: true,
|
|
3318
|
+
processed: 0,
|
|
3319
|
+
remainingDeadlineBreaches: 2,
|
|
3320
|
+
remainingDue: 2,
|
|
3321
|
+
});
|
|
3322
|
+
expect(settled[1].value).toEqual({
|
|
3323
|
+
continuationRequired: false,
|
|
3324
|
+
reported: 2,
|
|
3325
|
+
});
|
|
3326
|
+
expect(reportedValues).toEqual([2]);
|
|
3327
|
+
|
|
3328
|
+
const durableReports = await observer.query<{ resourceRef: string }>(
|
|
3329
|
+
\`SELECT resource_ref AS "resourceRef"
|
|
3330
|
+
FROM cleanup_deadline_reports
|
|
3331
|
+
WHERE feature = 'confidential-export'
|
|
3332
|
+
AND resource_ref = ANY($1::text[])
|
|
3333
|
+
ORDER BY resource_ref\`,
|
|
3334
|
+
[resourceRefs],
|
|
3335
|
+
);
|
|
3336
|
+
expect(durableReports.rows).toEqual([]);
|
|
3337
|
+
const activeArtifacts = await observer.query<{ approvalRequestId: string }>(
|
|
3338
|
+
\`SELECT approval_request_id::text AS "approvalRequestId"
|
|
3339
|
+
FROM data_lifecycle_export_artifacts
|
|
3340
|
+
WHERE approval_request_id = ANY($1::uuid[])
|
|
3341
|
+
ORDER BY approval_request_id\`,
|
|
3342
|
+
[resourceRefs],
|
|
3343
|
+
);
|
|
3344
|
+
expect(
|
|
3345
|
+
activeArtifacts.rows.map(({ approvalRequestId }) => approvalRequestId),
|
|
3346
|
+
).toEqual(resourceRefs);
|
|
3347
|
+
} finally {
|
|
3348
|
+
await observer
|
|
3349
|
+
.query(
|
|
3350
|
+
\`DELETE FROM cleanup_deadline_reports
|
|
3351
|
+
WHERE feature = 'confidential-export'
|
|
3352
|
+
AND resource_ref = ANY($1::text[])\`,
|
|
3353
|
+
[resourceRefs],
|
|
3354
|
+
)
|
|
3355
|
+
.catch(() => undefined);
|
|
3356
|
+
await observer
|
|
3357
|
+
.query(
|
|
3358
|
+
'DELETE FROM data_lifecycle_export_artifacts WHERE approval_request_id = ANY($1::uuid[])',
|
|
3359
|
+
[resourceRefs],
|
|
3360
|
+
)
|
|
3361
|
+
.catch(() => undefined);
|
|
3362
|
+
await observer
|
|
3363
|
+
.query(
|
|
3364
|
+
'DELETE FROM approval_requests WHERE id = ANY($1::uuid[])',
|
|
3365
|
+
[resourceRefs],
|
|
3366
|
+
)
|
|
3367
|
+
.catch(() => undefined);
|
|
3368
|
+
await Promise.all([
|
|
3369
|
+
cleanup.end().catch(() => undefined),
|
|
3370
|
+
reporter.end().catch(() => undefined),
|
|
3371
|
+
observer.end().catch(() => undefined),
|
|
3372
|
+
]);
|
|
3373
|
+
}
|
|
3374
|
+
});
|
|
3375
|
+
|
|
3376
|
+
it('serializes concurrent capacity decisions at the 1,000-report boundary', async () => {
|
|
3377
|
+
const deadlineAt = new Date('2026-08-02T12:00:00.000Z');
|
|
3378
|
+
const writerRefs = [
|
|
3379
|
+
'capacity-proof-writer-a',
|
|
3380
|
+
'capacity-proof-writer-b',
|
|
3381
|
+
] as const;
|
|
3382
|
+
const first = new Client({ connectionString: databaseUrl });
|
|
3383
|
+
const second = new Client({ connectionString: databaseUrl });
|
|
3384
|
+
const observer = new Client({ connectionString: databaseUrl });
|
|
3385
|
+
const firstCapacityLocked = deferred();
|
|
3386
|
+
const releaseFirstCapacity = deferred();
|
|
3387
|
+
try {
|
|
3388
|
+
await Promise.all([first.connect(), second.connect(), observer.connect()]);
|
|
3389
|
+
await first.query("SET lock_timeout = '10s'");
|
|
3390
|
+
await second.query("SET lock_timeout = '0'");
|
|
3391
|
+
for (const client of [first, second]) {
|
|
3392
|
+
await client.query("SET statement_timeout = '20s'");
|
|
3393
|
+
}
|
|
3394
|
+
await observer.query(
|
|
3395
|
+
\`DELETE FROM cleanup_deadline_reports
|
|
3396
|
+
WHERE feature = 'confidential-export'
|
|
3397
|
+
AND resource_ref LIKE 'capacity-proof-%'\`,
|
|
3398
|
+
);
|
|
3399
|
+
const existing = await observer.query<{ count: string }>(
|
|
3400
|
+
\`SELECT count(*)::text AS count
|
|
3401
|
+
FROM cleanup_deadline_reports
|
|
3402
|
+
WHERE feature = 'confidential-export'\`,
|
|
3403
|
+
);
|
|
3404
|
+
const existingCount = Number(existing.rows[0]?.count ?? 0);
|
|
3405
|
+
if (existingCount > 999) {
|
|
3406
|
+
throw new Error(
|
|
3407
|
+
'Capacity proof requires at most 999 pre-existing reports.',
|
|
3408
|
+
);
|
|
3409
|
+
}
|
|
3410
|
+
const seedCount = 999 - existingCount;
|
|
3411
|
+
await observer.query(
|
|
3412
|
+
\`INSERT INTO cleanup_deadline_reports
|
|
3413
|
+
(deadline_at, feature, resource_ref)
|
|
3414
|
+
SELECT $1, 'confidential-export',
|
|
3415
|
+
'capacity-proof-seed-' || generate_series
|
|
3416
|
+
FROM generate_series(1, $2::integer)\`,
|
|
3417
|
+
[deadlineAt.toISOString(), seedCount],
|
|
3418
|
+
);
|
|
3419
|
+
const [firstBackend, secondBackend] = await Promise.all([
|
|
3420
|
+
first.query<{ processId: number }>(
|
|
3421
|
+
'SELECT pg_backend_pid() AS "processId"',
|
|
3422
|
+
),
|
|
3423
|
+
second.query<{ processId: number }>(
|
|
3424
|
+
'SELECT pg_backend_pid() AS "processId"',
|
|
3425
|
+
),
|
|
3426
|
+
]);
|
|
3427
|
+
const firstProcessId = firstBackend.rows[0]?.processId;
|
|
3428
|
+
const secondProcessId = secondBackend.rows[0]?.processId;
|
|
3429
|
+
if (!firstProcessId || !secondProcessId) {
|
|
3430
|
+
throw new Error('Writer database process identity is unavailable.');
|
|
3431
|
+
}
|
|
3432
|
+
|
|
3433
|
+
const writeReport = async (
|
|
3434
|
+
client: Client,
|
|
3435
|
+
resourceRef: string,
|
|
3436
|
+
pauseAfterCapacityLock = false,
|
|
3437
|
+
) => {
|
|
3438
|
+
await client.query('BEGIN');
|
|
3439
|
+
const database = {
|
|
3440
|
+
query: async (text: string, values?: readonly unknown[]) => {
|
|
3441
|
+
const result = await query(client, text, values);
|
|
3442
|
+
if (
|
|
3443
|
+
pauseAfterCapacityLock &&
|
|
3444
|
+
text.includes('FROM cleanup_deadline_report_capacity') &&
|
|
3445
|
+
text.includes('FOR UPDATE')
|
|
3446
|
+
) {
|
|
3447
|
+
firstCapacityLocked.resolve();
|
|
3448
|
+
await bounded(
|
|
3449
|
+
releaseFirstCapacity.promise,
|
|
3450
|
+
'Capacity serialization release',
|
|
3451
|
+
);
|
|
3452
|
+
}
|
|
3453
|
+
return result;
|
|
3454
|
+
},
|
|
3455
|
+
};
|
|
3456
|
+
try {
|
|
3457
|
+
await recordPreparedExportCleanupDeadlineReport(database as never, {
|
|
3458
|
+
approvalRequestId: resourceRef,
|
|
3459
|
+
cleanupDeadlineAt: deadlineAt,
|
|
3460
|
+
expiresAt: new Date('2026-08-02T09:00:00.000Z'),
|
|
3461
|
+
lifecycleStatus: 'artifact-deletion-in-progress',
|
|
3462
|
+
now: deadlineAt.toISOString(),
|
|
3463
|
+
uploadStartedAt: new Date('2026-08-01T12:00:00.000Z'),
|
|
3464
|
+
});
|
|
3465
|
+
await client.query('COMMIT');
|
|
3466
|
+
} catch (error) {
|
|
3467
|
+
await client.query('ROLLBACK').catch(() => undefined);
|
|
3468
|
+
throw error;
|
|
3469
|
+
}
|
|
3470
|
+
};
|
|
3471
|
+
|
|
3472
|
+
const firstWrite = writeReport(first, writerRefs[0], true);
|
|
3473
|
+
await bounded(firstCapacityLocked.promise, 'First capacity lock');
|
|
3474
|
+
const secondWrite = writeReport(second, writerRefs[1]);
|
|
3475
|
+
await waitForExpectedBlocker(
|
|
3476
|
+
observer,
|
|
3477
|
+
secondProcessId,
|
|
3478
|
+
firstProcessId,
|
|
3479
|
+
);
|
|
3480
|
+
releaseFirstCapacity.resolve();
|
|
3481
|
+
await bounded(
|
|
3482
|
+
Promise.all([firstWrite, secondWrite]),
|
|
3483
|
+
'Concurrent capacity-bound report writes',
|
|
3484
|
+
30_000,
|
|
3485
|
+
);
|
|
3486
|
+
|
|
3487
|
+
const finalInventory = await observer.query<{
|
|
3488
|
+
count: string;
|
|
3489
|
+
}>(
|
|
3490
|
+
\`SELECT count(*)::text AS count
|
|
3491
|
+
FROM cleanup_deadline_reports
|
|
3492
|
+
WHERE feature = 'confidential-export'\`,
|
|
3493
|
+
);
|
|
3494
|
+
expect(Number(finalInventory.rows[0]?.count)).toBe(1_000);
|
|
3495
|
+
const persistedWriters = await observer.query<{ resourceRef: string }>(
|
|
3496
|
+
\`SELECT resource_ref AS "resourceRef"
|
|
3497
|
+
FROM cleanup_deadline_reports
|
|
3498
|
+
WHERE feature = 'confidential-export'
|
|
3499
|
+
AND resource_ref = ANY($1::text[])
|
|
3500
|
+
ORDER BY resource_ref\`,
|
|
3501
|
+
[writerRefs],
|
|
3502
|
+
);
|
|
3503
|
+
expect(persistedWriters.rows).toHaveLength(1);
|
|
3504
|
+
} finally {
|
|
3505
|
+
releaseFirstCapacity.resolve();
|
|
3506
|
+
await Promise.all([
|
|
3507
|
+
first.query('ROLLBACK').catch(() => undefined),
|
|
3508
|
+
second.query('ROLLBACK').catch(() => undefined),
|
|
3509
|
+
]);
|
|
3510
|
+
await observer
|
|
3511
|
+
.query(
|
|
3512
|
+
\`DELETE FROM cleanup_deadline_reports
|
|
3513
|
+
WHERE feature = 'confidential-export'
|
|
3514
|
+
AND resource_ref LIKE 'capacity-proof-%'\`,
|
|
3515
|
+
)
|
|
3516
|
+
.catch(() => undefined);
|
|
3517
|
+
await Promise.all([
|
|
3518
|
+
first.end().catch(() => undefined),
|
|
3519
|
+
second.end().catch(() => undefined),
|
|
3520
|
+
observer.end().catch(() => undefined),
|
|
3521
|
+
]);
|
|
3522
|
+
}
|
|
3523
|
+
});
|
|
3524
|
+
});
|
|
3525
|
+
`;
|
|
3526
|
+
}
|
|
3527
|
+
function renderDataRightsTestSource(name, pascalName) {
|
|
3528
|
+
return `import {
|
|
3529
|
+
DeleteObjectsCommand,
|
|
3530
|
+
ListObjectVersionsCommand,
|
|
3531
|
+
PutObjectCommand,
|
|
3532
|
+
} from '@aws-sdk/client-s3';
|
|
3533
|
+
import {
|
|
3534
|
+
beginApprovalExecution,
|
|
3535
|
+
createApprovalRequest,
|
|
3536
|
+
decideApprovalRequest,
|
|
3537
|
+
type AuthorizationPrincipal,
|
|
3538
|
+
} from '@empire-builder-kit/runtime/authorization';
|
|
3539
|
+
import { parseDataLifecycleChangeEvent } from '@empire-builder-kit/runtime/data-lifecycle';
|
|
3540
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3541
|
+
import { createRecordCorrectionArtifacts } from '../data/record-correction.js';
|
|
3542
|
+
import {
|
|
3543
|
+
cleanupDueRecordExports,
|
|
3544
|
+
putEncryptedExport,
|
|
3545
|
+
recordExportCleanupDeadlineAt,
|
|
3546
|
+
recordExportExpiresAt,
|
|
3547
|
+
recordExportObjectKey,
|
|
3548
|
+
recordExportSchedule,
|
|
3549
|
+
recordFinalizedExportCleanupDeadlineReport,
|
|
3550
|
+
recordPreparedExportCleanupDeadlineReport,
|
|
3551
|
+
reportExportCleanupDeadlineBreaches,
|
|
3552
|
+
runExportObjectCleanupAttempt,
|
|
3553
|
+
type ExportObjectCleanupResult,
|
|
3554
|
+
type ExportObjectVersion,
|
|
3555
|
+
type RecordExportObjectStore,
|
|
3556
|
+
} from '../data/record-export.js';
|
|
3557
|
+
import { createRecordHold } from '../data/record-holds.js';
|
|
3558
|
+
import { createDataExportCleanupHandler } from '../jobs/data-export-cleanup.js';
|
|
3559
|
+
import {
|
|
3560
|
+
DATA_RIGHTS_POLICY_VERSION,
|
|
3561
|
+
dataRightsSubjectReference,
|
|
3562
|
+
requireDirectAdmin,
|
|
3563
|
+
} from '../data/data-rights-shared.js';
|
|
3564
|
+
|
|
3565
|
+
const directAdmin: AuthorizationPrincipal = {
|
|
3566
|
+
actor: { id: 'admin-1', type: 'user' },
|
|
3567
|
+
authority: { mode: 'direct' },
|
|
3568
|
+
kind: 'admin',
|
|
3569
|
+
roles: ['${name}-admin'],
|
|
3570
|
+
scopes: [],
|
|
3571
|
+
subject: { id: 'admin-1', type: 'user' },
|
|
3572
|
+
};
|
|
3573
|
+
|
|
3574
|
+
const directOwner: AuthorizationPrincipal = {
|
|
3575
|
+
actor: { id: 'record-owner', type: 'user' },
|
|
3576
|
+
authority: { mode: 'direct' },
|
|
3577
|
+
kind: 'customer',
|
|
3578
|
+
roles: [],
|
|
3579
|
+
scopes: [],
|
|
3580
|
+
subject: { id: 'record-owner', type: 'user' },
|
|
3581
|
+
};
|
|
3582
|
+
|
|
3583
|
+
const cleanupAgent: AuthorizationPrincipal = {
|
|
3584
|
+
actor: { id: '${name}:data-export-cleanup', type: 'service' },
|
|
3585
|
+
audience: '${name}:data-export-cleanup',
|
|
3586
|
+
authority: {
|
|
3587
|
+
machineIdentityId: '${name}:data-export-cleanup',
|
|
3588
|
+
mode: 'machine',
|
|
3589
|
+
},
|
|
3590
|
+
kind: 'agent',
|
|
3591
|
+
roles: [],
|
|
3592
|
+
scopes: [],
|
|
3593
|
+
subject: { id: '${name}:data-export-cleanup', type: 'service' },
|
|
3594
|
+
};
|
|
3595
|
+
|
|
3596
|
+
interface CleanupCandidate {
|
|
3597
|
+
approvalRequestId: string;
|
|
3598
|
+
cleanupDeadlineAt: Date | null;
|
|
3599
|
+
expiresAt: Date;
|
|
3600
|
+
recordReference: string;
|
|
3601
|
+
uploadStartedAt: Date | null;
|
|
3602
|
+
}
|
|
3603
|
+
|
|
3604
|
+
class FakeCleanupDatabase {
|
|
3605
|
+
clockTimestamp = new Date('2026-08-02T12:00:00.000Z');
|
|
3606
|
+
failCommitOnce = false;
|
|
3607
|
+
maximumPendingReports = ${PENDING_EXPORT_CLEANUP_REPORT_BOUND};
|
|
3608
|
+
readonly pendingReports = new Set<string>();
|
|
3609
|
+
readonly queries: string[] = [];
|
|
3610
|
+
readonly reportDeadlines = new Map<string, Date>();
|
|
3611
|
+
private reportDeadlineTransactionSnapshot: Map<string, Date> | null =
|
|
3612
|
+
null;
|
|
3613
|
+
private reportTransactionSnapshot: Set<string> | null = null;
|
|
3614
|
+
|
|
3615
|
+
constructor(readonly candidates: CleanupCandidate[]) {}
|
|
3616
|
+
|
|
3617
|
+
async query<Row>(
|
|
3618
|
+
text: string,
|
|
3619
|
+
values: readonly unknown[] = [],
|
|
3620
|
+
): Promise<{ rowCount: number; rows: Row[] }> {
|
|
3621
|
+
this.queries.push(text);
|
|
3622
|
+
if (text === 'BEGIN') {
|
|
3623
|
+
this.reportTransactionSnapshot = new Set(this.pendingReports);
|
|
3624
|
+
this.reportDeadlineTransactionSnapshot = new Map(
|
|
3625
|
+
this.reportDeadlines,
|
|
3626
|
+
);
|
|
3627
|
+
return { rowCount: 0, rows: [] };
|
|
3628
|
+
}
|
|
3629
|
+
if (text === 'COMMIT') {
|
|
3630
|
+
if (this.failCommitOnce) {
|
|
3631
|
+
this.failCommitOnce = false;
|
|
3632
|
+
throw new Error('simulated post-emission commit failure');
|
|
3633
|
+
}
|
|
3634
|
+
this.reportDeadlineTransactionSnapshot = null;
|
|
3635
|
+
this.reportTransactionSnapshot = null;
|
|
3636
|
+
return { rowCount: 0, rows: [] };
|
|
3637
|
+
}
|
|
3638
|
+
if (text === 'ROLLBACK') {
|
|
3639
|
+
if (this.reportTransactionSnapshot) {
|
|
3640
|
+
this.pendingReports.clear();
|
|
3641
|
+
for (const resourceRef of this.reportTransactionSnapshot) {
|
|
3642
|
+
this.pendingReports.add(resourceRef);
|
|
3643
|
+
}
|
|
3644
|
+
}
|
|
3645
|
+
if (this.reportDeadlineTransactionSnapshot) {
|
|
3646
|
+
this.reportDeadlines.clear();
|
|
3647
|
+
for (const [resourceRef, deadlineAt] of
|
|
3648
|
+
this.reportDeadlineTransactionSnapshot) {
|
|
3649
|
+
this.reportDeadlines.set(resourceRef, deadlineAt);
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3652
|
+
this.reportDeadlineTransactionSnapshot = null;
|
|
3653
|
+
this.reportTransactionSnapshot = null;
|
|
3654
|
+
return { rowCount: 0, rows: [] };
|
|
3655
|
+
}
|
|
3656
|
+
if (
|
|
3657
|
+
text.includes('FROM cleanup_deadline_report_capacity') &&
|
|
3658
|
+
text.includes('FOR UPDATE')
|
|
3659
|
+
) {
|
|
3660
|
+
return {
|
|
3661
|
+
rowCount: 1,
|
|
3662
|
+
rows: [
|
|
3663
|
+
{
|
|
3664
|
+
maximumPending: this.maximumPendingReports,
|
|
3665
|
+
} as Row,
|
|
3666
|
+
],
|
|
3667
|
+
};
|
|
3668
|
+
}
|
|
3669
|
+
if (
|
|
3670
|
+
text.includes('SELECT resource_ref AS "resourceRef"') &&
|
|
3671
|
+
text.includes('FROM cleanup_deadline_reports')
|
|
3672
|
+
) {
|
|
3673
|
+
const limit = Number(values[1]);
|
|
3674
|
+
const rows = [...this.pendingReports]
|
|
3675
|
+
.sort()
|
|
3676
|
+
.slice(0, limit)
|
|
3677
|
+
.map((resourceRef) => ({ resourceRef })) as Row[];
|
|
3678
|
+
return { rowCount: rows.length, rows };
|
|
3679
|
+
}
|
|
3680
|
+
if (text.includes('DELETE FROM cleanup_deadline_reports')) {
|
|
3681
|
+
for (const resourceRef of values[1] as readonly string[]) {
|
|
3682
|
+
this.pendingReports.delete(resourceRef);
|
|
3683
|
+
this.reportDeadlines.delete(resourceRef);
|
|
3684
|
+
}
|
|
3685
|
+
return { rowCount: 0, rows: [] };
|
|
3686
|
+
}
|
|
3687
|
+
if (text.includes('INSERT INTO cleanup_deadline_reports')) {
|
|
3688
|
+
const deadlineAt = new Date(String(values[0]));
|
|
3689
|
+
const resourceRef = String(values[2]);
|
|
3690
|
+
const bound = Number(values[3]);
|
|
3691
|
+
if (this.reportDeadlines.has(resourceRef)) {
|
|
3692
|
+
return { rowCount: 0, rows: [] };
|
|
3693
|
+
}
|
|
3694
|
+
if (Number.isFinite(bound) && this.pendingReports.size >= bound) {
|
|
3695
|
+
return { rowCount: 0, rows: [] };
|
|
3696
|
+
}
|
|
3697
|
+
this.reportDeadlines.set(resourceRef, deadlineAt);
|
|
3698
|
+
this.pendingReports.add(resourceRef);
|
|
3699
|
+
return { rowCount: 1, rows: [] };
|
|
3700
|
+
}
|
|
3701
|
+
if (text.includes('SELECT deadline_at AS "deadlineAt"')) {
|
|
3702
|
+
const resourceRef = String(values[1]);
|
|
3703
|
+
const deadlineAt = this.reportDeadlines.get(resourceRef);
|
|
3704
|
+
return {
|
|
3705
|
+
rowCount: deadlineAt ? 1 : 0,
|
|
3706
|
+
rows: deadlineAt ? ([{ deadlineAt }] as Row[]) : [],
|
|
3707
|
+
};
|
|
3708
|
+
}
|
|
3709
|
+
if (text.includes('SELECT clock_timestamp() AS "observedAt"')) {
|
|
3710
|
+
return {
|
|
3711
|
+
rowCount: 1,
|
|
3712
|
+
rows: [{ observedAt: this.clockTimestamp } as Row],
|
|
3713
|
+
};
|
|
3714
|
+
}
|
|
3715
|
+
if (text.includes('bounded_pending_reports')) {
|
|
3716
|
+
return {
|
|
3717
|
+
rowCount: 1,
|
|
3718
|
+
rows: [{ count: String(this.pendingReports.size) } as Row],
|
|
3719
|
+
};
|
|
3720
|
+
}
|
|
3721
|
+
if (
|
|
3722
|
+
text.includes('ORDER BY approval_request_id') &&
|
|
3723
|
+
text.includes('upload_started_at AS "uploadStartedAt"')
|
|
3724
|
+
) {
|
|
3725
|
+
const limit = Number(values[0]);
|
|
3726
|
+
return {
|
|
3727
|
+
rowCount: Math.min(this.candidates.length, limit),
|
|
3728
|
+
rows: this.candidates.slice(0, limit) as Row[],
|
|
3729
|
+
};
|
|
3730
|
+
}
|
|
3731
|
+
if (
|
|
3732
|
+
text.includes('ORDER BY expires_at, approval_request_id') &&
|
|
3733
|
+
text.includes('cleanup_deadline_at AS "cleanupDeadlineAt"')
|
|
3734
|
+
) {
|
|
3735
|
+
const now = Date.parse(String(values[0]));
|
|
3736
|
+
const limit = Number(values[1]);
|
|
3737
|
+
const cursorExpiresAt = values[2]
|
|
3738
|
+
? Date.parse(String(values[2]))
|
|
3739
|
+
: null;
|
|
3740
|
+
const cursorApprovalRequestId = values[3]
|
|
3741
|
+
? String(values[3])
|
|
3742
|
+
: null;
|
|
3743
|
+
const rows = this.candidates
|
|
3744
|
+
.filter(({ approvalRequestId, expiresAt }) => {
|
|
3745
|
+
if (expiresAt.getTime() > now) return false;
|
|
3746
|
+
if (cursorExpiresAt === null) return true;
|
|
3747
|
+
return (
|
|
3748
|
+
expiresAt.getTime() > cursorExpiresAt ||
|
|
3749
|
+
(expiresAt.getTime() === cursorExpiresAt &&
|
|
3750
|
+
approvalRequestId > (cursorApprovalRequestId ?? ''))
|
|
3751
|
+
);
|
|
3752
|
+
})
|
|
3753
|
+
.sort(
|
|
3754
|
+
(left, right) =>
|
|
3755
|
+
left.expiresAt.getTime() - right.expiresAt.getTime() ||
|
|
3756
|
+
(left.approvalRequestId < right.approvalRequestId
|
|
3757
|
+
? -1
|
|
3758
|
+
: left.approvalRequestId > right.approvalRequestId
|
|
3759
|
+
? 1
|
|
3760
|
+
: 0),
|
|
3761
|
+
)
|
|
3762
|
+
.slice(0, limit);
|
|
3763
|
+
return { rowCount: rows.length, rows: rows as Row[] };
|
|
3764
|
+
}
|
|
3765
|
+
if (
|
|
3766
|
+
text.includes('count(*) FILTER') &&
|
|
3767
|
+
text.includes('AS "remainingDeadlineBreaches"')
|
|
3768
|
+
) {
|
|
3769
|
+
const now = Date.parse(String(values[0]));
|
|
3770
|
+
const remainingDue = this.candidates.filter(
|
|
3771
|
+
({ expiresAt }) => expiresAt.getTime() <= now,
|
|
3772
|
+
).length;
|
|
3773
|
+
const remainingDeadlineBreaches = this.candidates.filter(
|
|
3774
|
+
({ cleanupDeadlineAt }) =>
|
|
3775
|
+
cleanupDeadlineAt === null ||
|
|
3776
|
+
cleanupDeadlineAt.getTime() <= now,
|
|
3777
|
+
).length;
|
|
3778
|
+
return {
|
|
3779
|
+
rowCount: 1,
|
|
3780
|
+
rows: [
|
|
3781
|
+
{
|
|
3782
|
+
remainingDeadlineBreaches:
|
|
3783
|
+
String(remainingDeadlineBreaches),
|
|
3784
|
+
remainingDue: String(remainingDue),
|
|
3785
|
+
} as Row,
|
|
3786
|
+
],
|
|
3787
|
+
};
|
|
3788
|
+
}
|
|
3789
|
+
throw new Error('Unexpected fake cleanup database query.');
|
|
3790
|
+
}
|
|
3791
|
+
}
|
|
3792
|
+
|
|
3793
|
+
function cleanupDependencies(
|
|
3794
|
+
database: FakeCleanupDatabase,
|
|
3795
|
+
cleanup: (candidate: CleanupCandidate) => Promise<'completed' | 'failed'>,
|
|
3796
|
+
) {
|
|
3797
|
+
return {
|
|
3798
|
+
cleanupExpiredRecordExport: (async (input: {
|
|
3799
|
+
approvalRequestId: string;
|
|
3800
|
+
}) => {
|
|
3801
|
+
const candidate = database.candidates.find(
|
|
3802
|
+
({ approvalRequestId }) =>
|
|
3803
|
+
approvalRequestId === input.approvalRequestId,
|
|
3804
|
+
);
|
|
3805
|
+
if (!candidate) throw new Error('Cleanup candidate is absent.');
|
|
3806
|
+
const status = await cleanup(candidate);
|
|
3807
|
+
if (status === 'completed') {
|
|
3808
|
+
database.candidates.splice(
|
|
3809
|
+
database.candidates.indexOf(candidate),
|
|
3810
|
+
1,
|
|
3811
|
+
);
|
|
3812
|
+
}
|
|
3813
|
+
return { deletedVersions: 1, status };
|
|
3814
|
+
}) as never,
|
|
3815
|
+
withDatabase: (async (
|
|
3816
|
+
_context: unknown,
|
|
3817
|
+
_workload: unknown,
|
|
3818
|
+
operation: (client: unknown) => Promise<unknown>,
|
|
3819
|
+
) => operation(database)) as never,
|
|
3820
|
+
};
|
|
3821
|
+
}
|
|
3822
|
+
|
|
3823
|
+
class FakeExportObjectStore implements RecordExportObjectStore {
|
|
3824
|
+
ambiguousDeleteError = false;
|
|
3825
|
+
private nextVersion = 0;
|
|
3826
|
+
readonly versions = new Map<
|
|
3827
|
+
string,
|
|
3828
|
+
{ isDeleteMarker: boolean; key: string }
|
|
3829
|
+
>();
|
|
3830
|
+
|
|
3831
|
+
async send(command: unknown): Promise<unknown> {
|
|
3832
|
+
if (command instanceof PutObjectCommand) {
|
|
3833
|
+
const versionId = 'version-' + String(++this.nextVersion);
|
|
3834
|
+
this.versions.set(versionId, {
|
|
3835
|
+
isDeleteMarker: false,
|
|
3836
|
+
key: command.input.Key ?? '',
|
|
3837
|
+
});
|
|
3838
|
+
return { VersionId: versionId };
|
|
3839
|
+
}
|
|
3840
|
+
if (command instanceof ListObjectVersionsCommand) {
|
|
3841
|
+
const prefix = command.input.Prefix ?? '';
|
|
3842
|
+
return {
|
|
3843
|
+
IsTruncated: false,
|
|
3844
|
+
Versions: [...this.versions.entries()]
|
|
3845
|
+
.filter(([, value]) => value.key === prefix)
|
|
3846
|
+
.map(([VersionId, value]) => ({
|
|
3847
|
+
Key: value.key,
|
|
3848
|
+
VersionId,
|
|
3849
|
+
})),
|
|
3850
|
+
};
|
|
3851
|
+
}
|
|
3852
|
+
if (command instanceof DeleteObjectsCommand) {
|
|
3853
|
+
const objects = command.input.Delete?.Objects ?? [];
|
|
3854
|
+
for (const object of objects) {
|
|
3855
|
+
if (object.VersionId) this.versions.delete(object.VersionId);
|
|
3856
|
+
}
|
|
3857
|
+
return this.ambiguousDeleteError && objects.length > 0
|
|
3858
|
+
? {
|
|
3859
|
+
Errors: [
|
|
3860
|
+
{
|
|
3861
|
+
Code: 'InternalError',
|
|
3862
|
+
Key: objects[0]?.Key,
|
|
3863
|
+
},
|
|
3864
|
+
],
|
|
3865
|
+
}
|
|
3866
|
+
: { Errors: [] };
|
|
3867
|
+
}
|
|
3868
|
+
throw new Error('Unexpected fake export object-store command.');
|
|
3869
|
+
}
|
|
3870
|
+
}
|
|
3871
|
+
|
|
3872
|
+
type DurableVersion = ExportObjectVersion & {
|
|
3873
|
+
status: 'deleted' | 'deletion-in-progress' | 'failed';
|
|
3874
|
+
};
|
|
3875
|
+
|
|
3876
|
+
function cleanupAttempt<Result>(
|
|
3877
|
+
objectKey: string,
|
|
3878
|
+
objectStore: FakeExportObjectStore,
|
|
3879
|
+
durable: Map<string, DurableVersion>,
|
|
3880
|
+
finalize: (result: ExportObjectCleanupResult) => Promise<Result>,
|
|
3881
|
+
): Promise<Result> {
|
|
3882
|
+
return runExportObjectCleanupAttempt({
|
|
3883
|
+
finalize,
|
|
3884
|
+
objectKey,
|
|
3885
|
+
objectStore,
|
|
3886
|
+
recordDiscovered: async (versions) => {
|
|
3887
|
+
for (const version of versions) {
|
|
3888
|
+
durable.set(version.versionId, {
|
|
3889
|
+
...version,
|
|
3890
|
+
status: 'deletion-in-progress',
|
|
3891
|
+
});
|
|
3892
|
+
}
|
|
3893
|
+
},
|
|
3894
|
+
trackedVersions: [...durable.values()].filter(
|
|
3895
|
+
({ status }) => status !== 'deleted',
|
|
3896
|
+
),
|
|
3897
|
+
});
|
|
3898
|
+
}
|
|
3899
|
+
|
|
3900
|
+
describe('${pascalName} generated data-rights workflows', () => {
|
|
3901
|
+
it('requires direct admin authority for hold and approval administration', () => {
|
|
3902
|
+
expect(requireDirectAdmin(directAdmin)).toBe(directAdmin);
|
|
3903
|
+
expect(() =>
|
|
3904
|
+
requireDirectAdmin({
|
|
3905
|
+
...directAdmin,
|
|
3906
|
+
audience: 'admin-1',
|
|
3907
|
+
authority: {
|
|
3908
|
+
delegatedBy: { id: 'admin-1', type: 'user' },
|
|
3909
|
+
delegationId: 'delegation-1',
|
|
3910
|
+
mode: 'delegated',
|
|
3911
|
+
},
|
|
3912
|
+
}),
|
|
3913
|
+
).toThrow(/direct human principal/i);
|
|
3914
|
+
expect(() => requireDirectAdmin(directOwner)).toThrow(/direct admin/i);
|
|
3915
|
+
});
|
|
3916
|
+
|
|
3917
|
+
it('builds an exact pseudonymous record hold with bounded effects', () => {
|
|
3918
|
+
const hold = createRecordHold(
|
|
3919
|
+
{
|
|
3920
|
+
effects: ['block-deletion', 'block-export'],
|
|
3921
|
+
expiresAt: '2026-08-20T12:00:00.000Z',
|
|
3922
|
+
principal: directAdmin,
|
|
3923
|
+
purpose: 'legal',
|
|
3924
|
+
recordId: '00000000-0000-4000-8000-000000000001',
|
|
3925
|
+
reviewAt: '2026-08-10T12:00:00.000Z',
|
|
3926
|
+
},
|
|
3927
|
+
'2026-08-01T12:00:00.000Z',
|
|
3928
|
+
);
|
|
3929
|
+
expect(hold).toMatchObject({
|
|
3930
|
+
categoryId: 'product-record',
|
|
3931
|
+
effects: ['block-deletion', 'block-export'],
|
|
3932
|
+
purpose: 'legal',
|
|
3933
|
+
status: 'active',
|
|
3934
|
+
});
|
|
3935
|
+
expect(hold.scopeRef).toMatch(/^opaque:/);
|
|
3936
|
+
expect(JSON.stringify(hold)).not.toContain(
|
|
3937
|
+
'00000000-0000-4000-8000-000000000001',
|
|
3938
|
+
);
|
|
3939
|
+
});
|
|
3940
|
+
|
|
3941
|
+
it('leaves scan and retry intervals inside the one-day maximum', () => {
|
|
3942
|
+
expect(recordExportExpiresAt('2026-08-01T12:00:00.000Z')).toBe(
|
|
3943
|
+
'2026-08-02T09:00:00.000Z',
|
|
3944
|
+
);
|
|
3945
|
+
expect(
|
|
3946
|
+
recordExportCleanupDeadlineAt('2026-08-01T12:00:00.000Z'),
|
|
3947
|
+
).toBe('2026-08-02T12:00:00.000Z');
|
|
3948
|
+
expect(
|
|
3949
|
+
recordExportSchedule('2026-08-01T12:00:00.000Z'),
|
|
3950
|
+
).toEqual({
|
|
3951
|
+
cleanupDeadlineAt: '2026-08-02T12:00:00.000Z',
|
|
3952
|
+
expiresAt: '2026-08-02T09:00:00.000Z',
|
|
3953
|
+
uploadStartedAt: '2026-08-01T12:00:00.000Z',
|
|
3954
|
+
});
|
|
3955
|
+
expect(() =>
|
|
3956
|
+
recordExportSchedule('2026-08-01T12:00:00.000Z', {
|
|
3957
|
+
cleanupDeadlineAt: '2026-08-02T12:00:00.000Z',
|
|
3958
|
+
expiresAt: '2026-08-02T08:59:59.999Z',
|
|
3959
|
+
uploadStartedAt: '2026-08-01T12:00:00.000Z',
|
|
3960
|
+
}),
|
|
3961
|
+
).toThrow(/schedule is inconsistent/i);
|
|
3962
|
+
expect(() =>
|
|
3963
|
+
recordExportSchedule('2026-08-01T12:00:00.000Z', {
|
|
3964
|
+
cleanupDeadlineAt: '2026-08-02T12:00:00.000Z',
|
|
3965
|
+
expiresAt: '2026-08-02T09:00:00.000Z',
|
|
3966
|
+
}),
|
|
3967
|
+
).toThrow(/schedule is incomplete/i);
|
|
3968
|
+
expect(
|
|
3969
|
+
recordExportObjectKey('00000000-0000-4000-8000-000000000001'),
|
|
3970
|
+
).toBe(
|
|
3971
|
+
'confidential-exports/00000000-0000-4000-8000-000000000001/record.json',
|
|
3972
|
+
);
|
|
3973
|
+
});
|
|
3974
|
+
|
|
3975
|
+
it('denies an export approval issued under a stale policy version', () => {
|
|
3976
|
+
const recordId = '00000000-0000-4000-8000-000000000001';
|
|
3977
|
+
const command = { recordId };
|
|
3978
|
+
const pending = createApprovalRequest({
|
|
3979
|
+
action: '${name}:record-export',
|
|
3980
|
+
author: directOwner,
|
|
3981
|
+
command,
|
|
3982
|
+
expiresAt: '2026-08-01T13:00:00.000Z',
|
|
3983
|
+
now: '2026-08-01T12:00:00.000Z',
|
|
3984
|
+
policyVersion: '1.0.0',
|
|
3985
|
+
resource: { id: recordId, type: 'record' },
|
|
3986
|
+
slice: '${name}',
|
|
3987
|
+
});
|
|
3988
|
+
expect(
|
|
3989
|
+
decideApprovalRequest(pending, {
|
|
3990
|
+
approver: directAdmin,
|
|
3991
|
+
expectedRequestDigest: pending.requestDigest,
|
|
3992
|
+
now: '2026-08-01T12:05:00.000Z',
|
|
3993
|
+
outcome: 'approved',
|
|
3994
|
+
reasonReference: 'privacy-review-stale',
|
|
3995
|
+
requiredPolicyVersion: '2.0.0',
|
|
3996
|
+
}),
|
|
3997
|
+
).toMatchObject({
|
|
3998
|
+
decision: {
|
|
3999
|
+
category: 'policy-stale',
|
|
4000
|
+
decision: 'deny',
|
|
4001
|
+
policyVersion: '2.0.0',
|
|
4002
|
+
},
|
|
4003
|
+
request: pending,
|
|
4004
|
+
});
|
|
4005
|
+
const approved = decideApprovalRequest(pending, {
|
|
4006
|
+
approver: directAdmin,
|
|
4007
|
+
expectedRequestDigest: pending.requestDigest,
|
|
4008
|
+
now: '2026-08-01T12:05:00.000Z',
|
|
4009
|
+
outcome: 'approved',
|
|
4010
|
+
reasonReference: 'privacy-review-1',
|
|
4011
|
+
requiredPolicyVersion: '1.0.0',
|
|
4012
|
+
}).request;
|
|
4013
|
+
|
|
4014
|
+
expect(
|
|
4015
|
+
beginApprovalExecution(approved, {
|
|
4016
|
+
action: '${name}:record-export',
|
|
4017
|
+
command,
|
|
4018
|
+
executionId: 'export:' + pending.id,
|
|
4019
|
+
now: '2026-08-01T12:06:00.000Z',
|
|
4020
|
+
requiredPolicyVersion: '2.0.0',
|
|
4021
|
+
resource: { id: recordId, type: 'record' },
|
|
4022
|
+
}),
|
|
4023
|
+
).toMatchObject({
|
|
4024
|
+
decision: {
|
|
4025
|
+
category: 'policy-stale',
|
|
4026
|
+
decision: 'deny',
|
|
4027
|
+
policyVersion: '2.0.0',
|
|
4028
|
+
},
|
|
4029
|
+
request: { status: 'approved' },
|
|
4030
|
+
});
|
|
4031
|
+
});
|
|
4032
|
+
|
|
4033
|
+
it('builds a correction ledger and minimized cross-Slice event together', () => {
|
|
4034
|
+
process.env.EVENT_BUS_NAME = 'fixture-events';
|
|
4035
|
+
process.env.EBK_STAGE = 'development';
|
|
4036
|
+
const artifacts = createRecordCorrectionArtifacts({
|
|
4037
|
+
correlationId: 'correction-1',
|
|
4038
|
+
name: 'Synthetic correction',
|
|
4039
|
+
now: '2026-08-01T12:00:00.000Z',
|
|
4040
|
+
principal: directAdmin,
|
|
4041
|
+
recordId: '00000000-0000-4000-8000-000000000001',
|
|
4042
|
+
runScope: 'unit3-fixture',
|
|
4043
|
+
});
|
|
4044
|
+
expect(artifacts.ledger.action).toBe('correction');
|
|
4045
|
+
expect(artifacts.ledger.pseudonymousKey).toMatch(/^opaque:/);
|
|
4046
|
+
const event = JSON.parse(artifacts.event.Detail ?? '{}');
|
|
4047
|
+
const lifecycle = parseDataLifecycleChangeEvent(event.data);
|
|
4048
|
+
expect(lifecycle).toMatchObject({
|
|
4049
|
+
action: 'correction',
|
|
4050
|
+
policyVersion: DATA_RIGHTS_POLICY_VERSION,
|
|
4051
|
+
sourceSlice: '${name}',
|
|
4052
|
+
});
|
|
4053
|
+
expect(JSON.stringify(lifecycle)).not.toContain(
|
|
4054
|
+
'00000000-0000-4000-8000-000000000001',
|
|
4055
|
+
);
|
|
4056
|
+
});
|
|
4057
|
+
|
|
4058
|
+
it('uses the runtime pseudonymous-reference implementation for subjects', () => {
|
|
4059
|
+
expect(dataRightsSubjectReference('subject-1')).toMatch(/^opaque:/);
|
|
4060
|
+
expect(dataRightsSubjectReference('subject-1')).toBe(
|
|
4061
|
+
dataRightsSubjectReference('subject-1'),
|
|
4062
|
+
);
|
|
4063
|
+
});
|
|
4064
|
+
|
|
4065
|
+
it('records cleanup deadline reports at exact production decision points', async () => {
|
|
4066
|
+
const schedule = {
|
|
4067
|
+
cleanupDeadlineAt: new Date('2026-08-02T12:00:00.000Z'),
|
|
4068
|
+
expiresAt: new Date('2026-08-02T09:00:00.000Z'),
|
|
4069
|
+
uploadStartedAt: new Date('2026-08-01T12:00:00.000Z'),
|
|
4070
|
+
};
|
|
4071
|
+
const completed = new FakeCleanupDatabase([]);
|
|
4072
|
+
await recordPreparedExportCleanupDeadlineReport(completed as never, {
|
|
4073
|
+
approvalRequestId: 'completed-export',
|
|
4074
|
+
...schedule,
|
|
4075
|
+
lifecycleStatus: 'completed',
|
|
4076
|
+
now: '2026-08-03T12:00:00.000Z',
|
|
4077
|
+
});
|
|
4078
|
+
expect(completed.pendingReports.size).toBe(0);
|
|
4079
|
+
|
|
4080
|
+
const beforeDeadline = new FakeCleanupDatabase([]);
|
|
4081
|
+
await recordPreparedExportCleanupDeadlineReport(
|
|
4082
|
+
beforeDeadline as never,
|
|
4083
|
+
{
|
|
4084
|
+
approvalRequestId: 'before-deadline-export',
|
|
4085
|
+
...schedule,
|
|
4086
|
+
lifecycleStatus: 'artifact-deletion-in-progress',
|
|
4087
|
+
now: '2026-08-02T11:59:59.999Z',
|
|
4088
|
+
},
|
|
4089
|
+
);
|
|
4090
|
+
expect(beforeDeadline.pendingReports.size).toBe(0);
|
|
4091
|
+
|
|
4092
|
+
const atDeadline = new FakeCleanupDatabase([]);
|
|
4093
|
+
await recordPreparedExportCleanupDeadlineReport(atDeadline as never, {
|
|
4094
|
+
approvalRequestId: 'at-deadline-export',
|
|
4095
|
+
...schedule,
|
|
4096
|
+
lifecycleStatus: 'artifact-deletion-in-progress',
|
|
4097
|
+
now: '2026-08-02T12:00:00.000Z',
|
|
4098
|
+
});
|
|
4099
|
+
expect(atDeadline.pendingReports).toEqual(
|
|
4100
|
+
new Set(['at-deadline-export']),
|
|
4101
|
+
);
|
|
4102
|
+
|
|
4103
|
+
const crossingDeadline = new FakeCleanupDatabase([]);
|
|
4104
|
+
crossingDeadline.clockTimestamp =
|
|
4105
|
+
new Date('2026-08-02T12:00:00.000Z');
|
|
4106
|
+
await recordFinalizedExportCleanupDeadlineReport(
|
|
4107
|
+
crossingDeadline as never,
|
|
4108
|
+
{
|
|
4109
|
+
approvalRequestId: 'crossing-deadline-export',
|
|
4110
|
+
...schedule,
|
|
4111
|
+
now: '2026-08-02T11:59:59.999Z',
|
|
4112
|
+
},
|
|
4113
|
+
);
|
|
4114
|
+
expect(crossingDeadline.pendingReports).toEqual(
|
|
4115
|
+
new Set(['crossing-deadline-export']),
|
|
4116
|
+
);
|
|
4117
|
+
|
|
4118
|
+
const conflict = new FakeCleanupDatabase([]);
|
|
4119
|
+
conflict.pendingReports.add('conflicting-export');
|
|
4120
|
+
conflict.reportDeadlines.set(
|
|
4121
|
+
'conflicting-export',
|
|
4122
|
+
new Date('2026-08-03T12:00:00.000Z'),
|
|
4123
|
+
);
|
|
4124
|
+
await expect(
|
|
4125
|
+
recordPreparedExportCleanupDeadlineReport(conflict as never, {
|
|
4126
|
+
approvalRequestId: 'conflicting-export',
|
|
4127
|
+
...schedule,
|
|
4128
|
+
lifecycleStatus: 'artifact-deletion-in-progress',
|
|
4129
|
+
now: '2026-08-02T12:00:00.000Z',
|
|
4130
|
+
}),
|
|
4131
|
+
).rejects.toThrow(/deadline report is inconsistent/i);
|
|
4132
|
+
});
|
|
4133
|
+
|
|
4134
|
+
it('keeps retained deletion authority independent of report inventory saturation', async () => {
|
|
4135
|
+
const schedule = {
|
|
4136
|
+
cleanupDeadlineAt: new Date('2026-08-02T12:00:00.000Z'),
|
|
4137
|
+
expiresAt: new Date('2026-08-02T09:00:00.000Z'),
|
|
4138
|
+
uploadStartedAt: new Date('2026-08-01T12:00:00.000Z'),
|
|
4139
|
+
};
|
|
4140
|
+
const saturated = new FakeCleanupDatabase([]);
|
|
4141
|
+
for (let index = 0; index < ${PENDING_EXPORT_CLEANUP_REPORT_BOUND}; index += 1) {
|
|
4142
|
+
const resourceRef = 'saturating-export-' + String(index);
|
|
4143
|
+
saturated.pendingReports.add(resourceRef);
|
|
4144
|
+
saturated.reportDeadlines.set(resourceRef, schedule.cleanupDeadlineAt);
|
|
4145
|
+
}
|
|
4146
|
+
|
|
4147
|
+
await expect(
|
|
4148
|
+
recordPreparedExportCleanupDeadlineReport(saturated as never, {
|
|
4149
|
+
approvalRequestId: 'overflow-export',
|
|
4150
|
+
...schedule,
|
|
4151
|
+
lifecycleStatus: 'artifact-deletion-in-progress',
|
|
4152
|
+
now: '2026-08-02T12:00:00.000Z',
|
|
4153
|
+
}),
|
|
4154
|
+
).resolves.toBeUndefined();
|
|
4155
|
+
expect(saturated.pendingReports.size).toBe(
|
|
4156
|
+
${PENDING_EXPORT_CLEANUP_REPORT_BOUND},
|
|
4157
|
+
);
|
|
4158
|
+
expect(saturated.pendingReports.has('overflow-export')).toBe(false);
|
|
4159
|
+
|
|
4160
|
+
const invalidCapacity = new FakeCleanupDatabase([]);
|
|
4161
|
+
invalidCapacity.maximumPendingReports =
|
|
4162
|
+
${PENDING_EXPORT_CLEANUP_REPORT_BOUND} - 1;
|
|
4163
|
+
await expect(
|
|
4164
|
+
recordPreparedExportCleanupDeadlineReport(invalidCapacity as never, {
|
|
4165
|
+
approvalRequestId: 'invalid-capacity-export',
|
|
4166
|
+
...schedule,
|
|
4167
|
+
lifecycleStatus: 'artifact-deletion-in-progress',
|
|
4168
|
+
now: '2026-08-02T12:00:00.000Z',
|
|
4169
|
+
}),
|
|
4170
|
+
).rejects.toThrow(/report capacity is invalid/i);
|
|
4171
|
+
});
|
|
4172
|
+
|
|
4173
|
+
it('treats a concurrently acknowledged report as observed, not corrupt', async () => {
|
|
4174
|
+
const schedule = {
|
|
4175
|
+
cleanupDeadlineAt: new Date('2026-08-02T12:00:00.000Z'),
|
|
4176
|
+
expiresAt: new Date('2026-08-02T09:00:00.000Z'),
|
|
4177
|
+
uploadStartedAt: new Date('2026-08-01T12:00:00.000Z'),
|
|
4178
|
+
};
|
|
4179
|
+
const acknowledged = new FakeCleanupDatabase([]);
|
|
4180
|
+
acknowledged.pendingReports.add('acknowledged-export');
|
|
4181
|
+
acknowledged.reportDeadlines.set(
|
|
4182
|
+
'acknowledged-export',
|
|
4183
|
+
schedule.cleanupDeadlineAt,
|
|
4184
|
+
);
|
|
4185
|
+
const query = acknowledged.query.bind(acknowledged);
|
|
4186
|
+
acknowledged.query = (async (text: string, values?: unknown[]) => {
|
|
4187
|
+
if (text.includes('INSERT INTO cleanup_deadline_reports')) {
|
|
4188
|
+
const result = await query(text, values);
|
|
4189
|
+
acknowledged.pendingReports.delete('acknowledged-export');
|
|
4190
|
+
acknowledged.reportDeadlines.delete('acknowledged-export');
|
|
4191
|
+
return result;
|
|
4192
|
+
}
|
|
4193
|
+
return query(text, values);
|
|
4194
|
+
}) as typeof acknowledged.query;
|
|
4195
|
+
|
|
4196
|
+
await expect(
|
|
4197
|
+
recordPreparedExportCleanupDeadlineReport(acknowledged as never, {
|
|
4198
|
+
approvalRequestId: 'acknowledged-export',
|
|
4199
|
+
...schedule,
|
|
4200
|
+
lifecycleStatus: 'artifact-deletion-in-progress',
|
|
4201
|
+
now: '2026-08-02T12:00:00.000Z',
|
|
4202
|
+
}),
|
|
4203
|
+
).resolves.toBeUndefined();
|
|
4204
|
+
});
|
|
4205
|
+
|
|
4206
|
+
it('reports cleanup deadline state before and after exact expiry', async () => {
|
|
4207
|
+
const approvalRequestId =
|
|
4208
|
+
'00000000-0000-4000-8000-000000000021';
|
|
4209
|
+
const candidate = (): CleanupCandidate => ({
|
|
4210
|
+
approvalRequestId,
|
|
4211
|
+
cleanupDeadlineAt: new Date('2026-08-02T12:00:00.000Z'),
|
|
4212
|
+
expiresAt: new Date('2026-08-02T09:00:00.000Z'),
|
|
4213
|
+
recordReference: 'opaque:record-21',
|
|
4214
|
+
uploadStartedAt: new Date('2026-08-01T12:00:00.000Z'),
|
|
4215
|
+
});
|
|
4216
|
+
|
|
4217
|
+
const beforeDeadline = new FakeCleanupDatabase([candidate()]);
|
|
4218
|
+
await expect(
|
|
4219
|
+
cleanupDueRecordExports(
|
|
4220
|
+
{
|
|
4221
|
+
correlationId: 'cleanup-before-deadline',
|
|
4222
|
+
maximumBatches: 1,
|
|
4223
|
+
now: '2026-08-02T11:59:59.999Z',
|
|
4224
|
+
principal: cleanupAgent,
|
|
4225
|
+
},
|
|
4226
|
+
cleanupDependencies(beforeDeadline, async () => 'completed'),
|
|
4227
|
+
),
|
|
4228
|
+
).resolves.toMatchObject({
|
|
4229
|
+
completed: 1,
|
|
4230
|
+
continuationRequired: false,
|
|
4231
|
+
deadlineBreached: false,
|
|
4232
|
+
remainingDeadlineBreaches: 0,
|
|
4233
|
+
remainingDue: 0,
|
|
4234
|
+
});
|
|
4235
|
+
|
|
4236
|
+
const atDeadline = new FakeCleanupDatabase([candidate()]);
|
|
4237
|
+
await expect(
|
|
4238
|
+
cleanupDueRecordExports(
|
|
4239
|
+
{
|
|
4240
|
+
correlationId: 'cleanup-at-deadline',
|
|
4241
|
+
maximumBatches: 1,
|
|
4242
|
+
now: '2026-08-02T12:00:00.000Z',
|
|
4243
|
+
principal: cleanupAgent,
|
|
4244
|
+
},
|
|
4245
|
+
cleanupDependencies(atDeadline, async () => 'completed'),
|
|
4246
|
+
),
|
|
4247
|
+
).resolves.toMatchObject({
|
|
4248
|
+
completed: 1,
|
|
4249
|
+
continuationRequired: false,
|
|
4250
|
+
deadlineBreached: true,
|
|
4251
|
+
remainingDeadlineBreaches: 0,
|
|
4252
|
+
remainingDue: 0,
|
|
4253
|
+
});
|
|
4254
|
+
|
|
4255
|
+
const failedCleanup = new FakeCleanupDatabase([candidate()]);
|
|
4256
|
+
await expect(
|
|
4257
|
+
cleanupDueRecordExports(
|
|
4258
|
+
{
|
|
4259
|
+
correlationId: 'cleanup-failed-at-deadline',
|
|
4260
|
+
maximumBatches: 1,
|
|
4261
|
+
now: '2026-08-02T12:00:00.000Z',
|
|
4262
|
+
principal: cleanupAgent,
|
|
4263
|
+
},
|
|
4264
|
+
cleanupDependencies(failedCleanup, async () => {
|
|
4265
|
+
throw new Error('simulated version cleanup failure');
|
|
4266
|
+
}),
|
|
4267
|
+
),
|
|
4268
|
+
).resolves.toMatchObject({
|
|
4269
|
+
completed: 0,
|
|
4270
|
+
continuationRequired: true,
|
|
4271
|
+
deadlineBreached: true,
|
|
4272
|
+
remainingDeadlineBreaches: 1,
|
|
4273
|
+
remainingDue: 1,
|
|
4274
|
+
retryRequired: 1,
|
|
4275
|
+
});
|
|
4276
|
+
expect(failedCleanup.queries).toEqual(
|
|
4277
|
+
expect.arrayContaining([
|
|
4278
|
+
expect.stringContaining(
|
|
4279
|
+
'cleanup_deadline_at AS "cleanupDeadlineAt"',
|
|
4280
|
+
),
|
|
4281
|
+
expect.stringContaining('AS "remainingDeadlineBreaches"'),
|
|
4282
|
+
]),
|
|
4283
|
+
);
|
|
4284
|
+
|
|
4285
|
+
const driftedSchedule = new FakeCleanupDatabase([
|
|
4286
|
+
{
|
|
4287
|
+
...candidate(),
|
|
4288
|
+
cleanupDeadlineAt: new Date('2026-08-03T12:00:00.000Z'),
|
|
4289
|
+
expiresAt: new Date('2026-08-03T09:00:00.000Z'),
|
|
4290
|
+
},
|
|
4291
|
+
]);
|
|
4292
|
+
await expect(
|
|
4293
|
+
cleanupDueRecordExports(
|
|
4294
|
+
{
|
|
4295
|
+
correlationId: 'cleanup-drifted-schedule',
|
|
4296
|
+
maximumBatches: 1,
|
|
4297
|
+
now: '2026-08-02T11:59:59.999Z',
|
|
4298
|
+
principal: cleanupAgent,
|
|
4299
|
+
},
|
|
4300
|
+
cleanupDependencies(driftedSchedule, async () => 'completed'),
|
|
4301
|
+
),
|
|
4302
|
+
).rejects.toThrow(/schedule is inconsistent/i);
|
|
4303
|
+
|
|
4304
|
+
const incompleteSchedule = new FakeCleanupDatabase([
|
|
4305
|
+
{ ...candidate(), cleanupDeadlineAt: null },
|
|
4306
|
+
]);
|
|
4307
|
+
await expect(
|
|
4308
|
+
cleanupDueRecordExports(
|
|
4309
|
+
{
|
|
4310
|
+
correlationId: 'cleanup-incomplete-schedule',
|
|
4311
|
+
maximumBatches: 1,
|
|
4312
|
+
now: '2026-08-02T11:59:59.999Z',
|
|
4313
|
+
principal: cleanupAgent,
|
|
4314
|
+
},
|
|
4315
|
+
cleanupDependencies(incompleteSchedule, async () => 'completed'),
|
|
4316
|
+
),
|
|
4317
|
+
).rejects.toThrow(/schedule is incomplete/i);
|
|
4318
|
+
});
|
|
4319
|
+
|
|
4320
|
+
it('durably records an overdue bounded inventory when no cleanup loop can run', async () => {
|
|
4321
|
+
const approvalRequestId =
|
|
4322
|
+
'00000000-0000-4000-8000-000000000022';
|
|
4323
|
+
const database = new FakeCleanupDatabase([
|
|
4324
|
+
{
|
|
4325
|
+
approvalRequestId,
|
|
4326
|
+
cleanupDeadlineAt: new Date('2026-08-02T12:00:00.000Z'),
|
|
4327
|
+
expiresAt: new Date('2026-08-02T09:00:00.000Z'),
|
|
4328
|
+
recordReference: 'opaque:record-22',
|
|
4329
|
+
uploadStartedAt: new Date('2026-08-01T12:00:00.000Z'),
|
|
4330
|
+
},
|
|
4331
|
+
]);
|
|
4332
|
+
const cleanup = vi.fn().mockResolvedValue('completed');
|
|
4333
|
+
const invocation = {
|
|
4334
|
+
correlationId: 'cleanup-no-loop-deadline',
|
|
4335
|
+
maximumBatches: 1,
|
|
4336
|
+
now: '2026-08-02T12:00:00.000Z',
|
|
4337
|
+
principal: cleanupAgent,
|
|
4338
|
+
shouldContinue: () => false,
|
|
4339
|
+
};
|
|
4340
|
+
|
|
4341
|
+
await expect(
|
|
4342
|
+
cleanupDueRecordExports(
|
|
4343
|
+
invocation,
|
|
4344
|
+
cleanupDependencies(database, cleanup),
|
|
4345
|
+
),
|
|
4346
|
+
).resolves.toMatchObject({
|
|
4347
|
+
batches: 0,
|
|
4348
|
+
continuationRequired: true,
|
|
4349
|
+
deadlineBreached: true,
|
|
4350
|
+
processed: 0,
|
|
4351
|
+
remainingDeadlineBreaches: 1,
|
|
4352
|
+
remainingDue: 1,
|
|
4353
|
+
});
|
|
4354
|
+
await expect(
|
|
4355
|
+
cleanupDueRecordExports(
|
|
4356
|
+
invocation,
|
|
4357
|
+
cleanupDependencies(database, cleanup),
|
|
4358
|
+
),
|
|
4359
|
+
).resolves.toMatchObject({
|
|
4360
|
+
batches: 0,
|
|
4361
|
+
deadlineBreached: true,
|
|
4362
|
+
processed: 0,
|
|
4363
|
+
});
|
|
4364
|
+
expect(cleanup).not.toHaveBeenCalled();
|
|
4365
|
+
expect(database.pendingReports).toEqual(
|
|
4366
|
+
new Set([approvalRequestId]),
|
|
4367
|
+
);
|
|
4368
|
+
expect(database.queries.filter((query) => query === 'BEGIN')).toHaveLength(2);
|
|
4369
|
+
expect(database.queries.filter((query) => query === 'COMMIT')).toHaveLength(2);
|
|
4370
|
+
});
|
|
4371
|
+
|
|
4372
|
+
it('commits an overdue deadline report before cleanup can fail', async () => {
|
|
4373
|
+
const approvalRequestId =
|
|
4374
|
+
'00000000-0000-4000-8000-000000000023';
|
|
4375
|
+
const database = new FakeCleanupDatabase([
|
|
4376
|
+
{
|
|
4377
|
+
approvalRequestId,
|
|
4378
|
+
cleanupDeadlineAt: new Date('2026-08-02T12:00:00.000Z'),
|
|
4379
|
+
expiresAt: new Date('2026-08-02T09:00:00.000Z'),
|
|
4380
|
+
recordReference: 'opaque:record-23',
|
|
4381
|
+
uploadStartedAt: new Date('2026-08-01T12:00:00.000Z'),
|
|
4382
|
+
},
|
|
4383
|
+
]);
|
|
4384
|
+
const cleanup = vi.fn().mockImplementation(async () => {
|
|
4385
|
+
expect(database.pendingReports).toEqual(
|
|
4386
|
+
new Set([approvalRequestId]),
|
|
4387
|
+
);
|
|
4388
|
+
expect(database.queries).toContain('COMMIT');
|
|
4389
|
+
throw new Error('simulated failure before cleanup reporting');
|
|
4390
|
+
});
|
|
4391
|
+
|
|
4392
|
+
await expect(
|
|
4393
|
+
cleanupDueRecordExports(
|
|
4394
|
+
{
|
|
4395
|
+
correlationId: 'cleanup-pre-report-failure',
|
|
4396
|
+
maximumBatches: 1,
|
|
4397
|
+
now: '2026-08-02T12:00:00.000Z',
|
|
4398
|
+
principal: cleanupAgent,
|
|
4399
|
+
},
|
|
4400
|
+
cleanupDependencies(database, cleanup),
|
|
4401
|
+
),
|
|
4402
|
+
).resolves.toMatchObject({
|
|
4403
|
+
completed: 0,
|
|
4404
|
+
continuationRequired: true,
|
|
4405
|
+
deadlineBreached: true,
|
|
4406
|
+
retryRequired: 1,
|
|
4407
|
+
});
|
|
4408
|
+
expect(cleanup).toHaveBeenCalledOnce();
|
|
4409
|
+
expect(database.pendingReports).toEqual(
|
|
4410
|
+
new Set([approvalRequestId]),
|
|
4411
|
+
);
|
|
4412
|
+
});
|
|
4413
|
+
|
|
4414
|
+
it('emits one deadline signal and fails after best-effort cleanup', async () => {
|
|
4415
|
+
process.env.EBK_STAGE = 'test';
|
|
4416
|
+
process.env.EBK_WORKLOAD_IDENTITY =
|
|
4417
|
+
'${name}:data-export-cleanup';
|
|
4418
|
+
const emitted: Array<{ metricName?: string; value?: number }> = [];
|
|
4419
|
+
const authorize = vi.fn().mockResolvedValue(undefined);
|
|
4420
|
+
const cleanup = vi.fn().mockResolvedValue({
|
|
4421
|
+
batches: 1,
|
|
4422
|
+
completed: 1,
|
|
4423
|
+
continuationRequired: false,
|
|
4424
|
+
deadlineBreached: true,
|
|
4425
|
+
processed: 1,
|
|
4426
|
+
remainingDeadlineBreaches: 0,
|
|
4427
|
+
remainingDue: 0,
|
|
4428
|
+
retryRequired: 0,
|
|
4429
|
+
});
|
|
4430
|
+
const report = vi
|
|
4431
|
+
.fn()
|
|
4432
|
+
.mockImplementationOnce(async (input: { report(value: number): boolean }) => {
|
|
4433
|
+
expect(input.report(0)).toBe(true);
|
|
4434
|
+
return { continuationRequired: false, reported: 0 };
|
|
4435
|
+
})
|
|
4436
|
+
.mockImplementationOnce(async (input: { report(value: number): boolean }) => {
|
|
4437
|
+
expect(input.report(1)).toBe(true);
|
|
4438
|
+
return { continuationRequired: false, reported: 1 };
|
|
4439
|
+
});
|
|
4440
|
+
const handler = createDataExportCleanupHandler({
|
|
4441
|
+
authorize: authorize as never,
|
|
4442
|
+
cleanup: cleanup as never,
|
|
4443
|
+
metrics: {
|
|
4444
|
+
emit: (metric: { metricName?: string; value?: number }) => {
|
|
4445
|
+
emitted.push(metric);
|
|
4446
|
+
return {} as never;
|
|
4447
|
+
},
|
|
4448
|
+
} as never,
|
|
4449
|
+
report: report as never,
|
|
4450
|
+
});
|
|
4451
|
+
await expect(
|
|
4452
|
+
handler(
|
|
4453
|
+
{ id: 'cleanup-deadline-proof' },
|
|
4454
|
+
{ getRemainingTimeInMillis: () => 60_000 },
|
|
4455
|
+
),
|
|
4456
|
+
).rejects.toThrow('confidential-export-cleanup-deadline-breached');
|
|
4457
|
+
expect(authorize).toHaveBeenCalledOnce();
|
|
4458
|
+
expect(cleanup).toHaveBeenCalledOnce();
|
|
4459
|
+
expect(report).toHaveBeenCalledTimes(2);
|
|
4460
|
+
expect(emitted).toContainEqual(
|
|
4461
|
+
expect.objectContaining({
|
|
4462
|
+
metricName: 'DataExportCleanupDeadlineBreaches',
|
|
4463
|
+
value: 1,
|
|
4464
|
+
}),
|
|
4465
|
+
);
|
|
4466
|
+
});
|
|
4467
|
+
|
|
4468
|
+
it('retries a durable deadline report after post-emission commit failure', async () => {
|
|
4469
|
+
const database = new FakeCleanupDatabase([]);
|
|
4470
|
+
database.pendingReports.add(
|
|
4471
|
+
'00000000-0000-4000-8000-000000000021',
|
|
4472
|
+
);
|
|
4473
|
+
database.failCommitOnce = true;
|
|
4474
|
+
const firstEmission: number[] = [];
|
|
4475
|
+
await expect(
|
|
4476
|
+
reportExportCleanupDeadlineBreaches(
|
|
4477
|
+
{
|
|
4478
|
+
correlationId: 'cleanup-report-crash',
|
|
4479
|
+
report: (value) => {
|
|
4480
|
+
firstEmission.push(value);
|
|
4481
|
+
return true;
|
|
4482
|
+
},
|
|
4483
|
+
},
|
|
4484
|
+
{ withDatabase: cleanupDependencies(database, async () => 'completed').withDatabase },
|
|
4485
|
+
),
|
|
4486
|
+
).rejects.toThrow('simulated post-emission commit failure');
|
|
4487
|
+
expect(firstEmission).toEqual([1]);
|
|
4488
|
+
expect(database.pendingReports.size).toBe(1);
|
|
4489
|
+
|
|
4490
|
+
const retryEmission: number[] = [];
|
|
4491
|
+
await expect(
|
|
4492
|
+
reportExportCleanupDeadlineBreaches(
|
|
4493
|
+
{
|
|
4494
|
+
correlationId: 'cleanup-report-retry',
|
|
4495
|
+
report: (value) => {
|
|
4496
|
+
retryEmission.push(value);
|
|
4497
|
+
return true;
|
|
4498
|
+
},
|
|
4499
|
+
},
|
|
4500
|
+
{ withDatabase: cleanupDependencies(database, async () => 'completed').withDatabase },
|
|
4501
|
+
),
|
|
4502
|
+
).resolves.toEqual({ continuationRequired: false, reported: 1 });
|
|
4503
|
+
expect(retryEmission).toEqual([1]);
|
|
4504
|
+
expect(database.pendingReports.size).toBe(0);
|
|
4505
|
+
|
|
4506
|
+
const afterAcknowledgement: number[] = [];
|
|
4507
|
+
await reportExportCleanupDeadlineBreaches(
|
|
4508
|
+
{
|
|
4509
|
+
correlationId: 'cleanup-report-after-ack',
|
|
4510
|
+
report: (value) => {
|
|
4511
|
+
afterAcknowledgement.push(value);
|
|
4512
|
+
return true;
|
|
4513
|
+
},
|
|
4514
|
+
},
|
|
4515
|
+
{ withDatabase: cleanupDependencies(database, async () => 'completed').withDatabase },
|
|
4516
|
+
);
|
|
4517
|
+
expect(afterAcknowledgement).toEqual([0]);
|
|
4518
|
+
});
|
|
4519
|
+
|
|
4520
|
+
it('recovers a post-put database rollback through durable orphan cleanup', async () => {
|
|
4521
|
+
process.env.DATA_EXPORT_BUCKET_NAME = 'fixture-export-bucket';
|
|
4522
|
+
process.env.DATA_EXPORT_KMS_KEY_ARN =
|
|
4523
|
+
'arn:aws:kms:us-east-1:111122223333:key/fixture';
|
|
4524
|
+
const objectKey = recordExportObjectKey(
|
|
4525
|
+
'00000000-0000-4000-8000-000000000011',
|
|
4526
|
+
);
|
|
4527
|
+
const objectStore = new FakeExportObjectStore();
|
|
4528
|
+
await expect(
|
|
4529
|
+
(async () => {
|
|
4530
|
+
await putEncryptedExport(objectKey, '{"id":"fixture"}', objectStore);
|
|
4531
|
+
throw new Error('simulated database rollback after PutObject');
|
|
4532
|
+
})(),
|
|
4533
|
+
).rejects.toThrow(/database rollback/);
|
|
4534
|
+
expect(objectStore.versions.size).toBe(1);
|
|
4535
|
+
const durable = new Map<string, DurableVersion>();
|
|
4536
|
+
const result = await cleanupAttempt(
|
|
4537
|
+
objectKey,
|
|
4538
|
+
objectStore,
|
|
4539
|
+
durable,
|
|
4540
|
+
async (attempt) => {
|
|
4541
|
+
for (const version of attempt.attemptedVersions) {
|
|
4542
|
+
durable.set(version.versionId, {
|
|
4543
|
+
...version,
|
|
4544
|
+
status: attempt.failedVersionIds.includes(version.versionId)
|
|
4545
|
+
? 'failed'
|
|
4546
|
+
: 'deleted',
|
|
4547
|
+
});
|
|
4548
|
+
}
|
|
4549
|
+
return attempt;
|
|
4550
|
+
},
|
|
4551
|
+
);
|
|
4552
|
+
expect(result.failedVersionIds).toEqual([]);
|
|
4553
|
+
expect(objectStore.versions.size).toBe(0);
|
|
4554
|
+
expect([...durable.values()].map(({ status }) => status)).toEqual([
|
|
4555
|
+
'deleted',
|
|
4556
|
+
]);
|
|
4557
|
+
});
|
|
4558
|
+
|
|
4559
|
+
it('retries durable inventory after delete succeeds and DB finalization rolls back', async () => {
|
|
4560
|
+
process.env.DATA_EXPORT_BUCKET_NAME = 'fixture-export-bucket';
|
|
4561
|
+
process.env.DATA_EXPORT_KMS_KEY_ARN =
|
|
4562
|
+
'arn:aws:kms:us-east-1:111122223333:key/fixture';
|
|
4563
|
+
const objectKey = recordExportObjectKey(
|
|
4564
|
+
'00000000-0000-4000-8000-000000000012',
|
|
4565
|
+
);
|
|
4566
|
+
const objectStore = new FakeExportObjectStore();
|
|
4567
|
+
await putEncryptedExport(objectKey, '{"id":"fixture"}', objectStore);
|
|
4568
|
+
const durable = new Map<string, DurableVersion>();
|
|
4569
|
+
await expect(
|
|
4570
|
+
cleanupAttempt(objectKey, objectStore, durable, async () => {
|
|
4571
|
+
throw new Error('simulated cleanup finalization rollback');
|
|
4572
|
+
}),
|
|
4573
|
+
).rejects.toThrow(/finalization rollback/);
|
|
4574
|
+
expect(objectStore.versions.size).toBe(0);
|
|
4575
|
+
expect([...durable.values()][0]?.status).toBe('deletion-in-progress');
|
|
4576
|
+
const retried = await cleanupAttempt(
|
|
4577
|
+
objectKey,
|
|
4578
|
+
objectStore,
|
|
4579
|
+
durable,
|
|
4580
|
+
async (attempt) => attempt,
|
|
4581
|
+
);
|
|
4582
|
+
expect(retried.attemptedVersions).toHaveLength(1);
|
|
4583
|
+
expect(retried.failedVersionIds).toEqual([]);
|
|
4584
|
+
expect(retried.remainingVersions).toEqual([]);
|
|
4585
|
+
});
|
|
4586
|
+
|
|
4587
|
+
it('fails a missing-VersionId delete error closed and tolerates overlapping finalization', async () => {
|
|
4588
|
+
process.env.DATA_EXPORT_BUCKET_NAME = 'fixture-export-bucket';
|
|
4589
|
+
process.env.DATA_EXPORT_KMS_KEY_ARN =
|
|
4590
|
+
'arn:aws:kms:us-east-1:111122223333:key/fixture';
|
|
4591
|
+
const objectKey = recordExportObjectKey(
|
|
4592
|
+
'00000000-0000-4000-8000-000000000013',
|
|
4593
|
+
);
|
|
4594
|
+
const objectStore = new FakeExportObjectStore();
|
|
4595
|
+
const versionId = await putEncryptedExport(
|
|
4596
|
+
objectKey,
|
|
4597
|
+
'{"id":"fixture"}',
|
|
4598
|
+
objectStore,
|
|
4599
|
+
);
|
|
4600
|
+
objectStore.ambiguousDeleteError = true;
|
|
4601
|
+
const durable = new Map<string, DurableVersion>();
|
|
4602
|
+
const failed = await cleanupAttempt(
|
|
4603
|
+
objectKey,
|
|
4604
|
+
objectStore,
|
|
4605
|
+
durable,
|
|
4606
|
+
async (attempt) => attempt,
|
|
4607
|
+
);
|
|
4608
|
+
expect(failed.failedVersionIds).toEqual([versionId]);
|
|
4609
|
+
|
|
4610
|
+
objectStore.ambiguousDeleteError = false;
|
|
4611
|
+
let completed = false;
|
|
4612
|
+
const finalize = async (attempt: ExportObjectCleanupResult) => {
|
|
4613
|
+
await Promise.resolve();
|
|
4614
|
+
if (!completed && attempt.failedVersionIds.length === 0) {
|
|
4615
|
+
completed = true;
|
|
4616
|
+
}
|
|
4617
|
+
return completed ? 'completed' : 'retry-required';
|
|
4618
|
+
};
|
|
4619
|
+
const results = await Promise.all([
|
|
4620
|
+
cleanupAttempt(objectKey, objectStore, durable, finalize),
|
|
4621
|
+
cleanupAttempt(objectKey, objectStore, durable, finalize),
|
|
4622
|
+
]);
|
|
4623
|
+
expect(results).toEqual(['completed', 'completed']);
|
|
4624
|
+
});
|
|
4625
|
+
});
|
|
4626
|
+
`;
|
|
4627
|
+
}
|
|
4628
|
+
//# sourceMappingURL=built-in-plan-data-rights-files.js.map
|