chainlesschain 0.166.14 → 0.166.16
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/package.json +1 -1
- package/src/assets/web-panel/assets/{AIOps-C8SyFhOo.js → AIOps-CTxa2Wbq.js} +1 -1
- package/src/assets/web-panel/assets/{ActionButton-Dup5gFK0.js → ActionButton-DHrQyYhy.js} +1 -1
- package/src/assets/web-panel/assets/{Analytics-8FyOWA7V.js → Analytics-DQiUqcNl.js} +3 -3
- package/src/assets/web-panel/assets/{AppLayout-DBzdU9dg.js → AppLayout-_Iz4r6wt.js} +5 -5
- package/src/assets/web-panel/assets/{Artifacts-C_0Z8gNy.js → Artifacts-BNs63YHo.js} +1 -1
- package/src/assets/web-panel/assets/{Audit-Cf2a-58m.js → Audit-BgHIqRLn.js} +1 -1
- package/src/assets/web-panel/assets/{BackgroundAgents-M7w-2zgB.js → BackgroundAgents-DUQ5axBB.js} +1 -1
- package/src/assets/web-panel/assets/{Backup-CCTHQXoL.js → Backup-CJ51E9Vw.js} +1 -1
- package/src/assets/web-panel/assets/{BaseInput-DjXpeFwJ.js → BaseInput-o-5toA_R.js} +1 -1
- package/src/assets/web-panel/assets/{Chat-cAvchsJU.js → Chat-Cu7a-x1L.js} +6 -6
- package/src/assets/web-panel/assets/{ChatBubbleRenderer-DvEvxlZv.js → ChatBubbleRenderer-BUyYkAXr.js} +1 -1
- package/src/assets/web-panel/assets/{Checkbox-DKs6qvOn.js → Checkbox-DwdmZ71L.js} +1 -1
- package/src/assets/web-panel/assets/{Codegen-DlkA7b9X.js → Codegen-BrH2eYiP.js} +1 -1
- package/src/assets/web-panel/assets/{Col-CkfdKhyJ.js → Col-BVJQEP2j.js} +1 -1
- package/src/assets/web-panel/assets/{Community-iNXMHzj6.js → Community-FGO3vZPq.js} +1 -1
- package/src/assets/web-panel/assets/{Compact-DQxPEd09.js → Compact-DYGAFVnT.js} +1 -1
- package/src/assets/web-panel/assets/{Compliance-CSKvMCjs.js → Compliance-CRvc8Bdt.js} +1 -1
- package/src/assets/web-panel/assets/{Cowork-CkWqazpD.js → Cowork-D6Ah2m7A.js} +3 -3
- package/src/assets/web-panel/assets/{Cron-k6W8IzhB.js → Cron-AXbU2ifR.js} +2 -2
- package/src/assets/web-panel/assets/{Crosschain-C8MS1OuY.js → Crosschain-C5DXgEFd.js} +1 -1
- package/src/assets/web-panel/assets/{DID-DJuCcgiQ.js → DID-Bxh9QV9v.js} +2 -2
- package/src/assets/web-panel/assets/{Dashboard-LeThKzp3.js → Dashboard-B-ZHEta-.js} +2 -2
- package/src/assets/web-panel/assets/{Dropdown-BvxwgpE4.js → Dropdown-66Ik1jqa.js} +1 -1
- package/src/assets/web-panel/assets/{EmailListRenderer-n3e_a1M3.js → EmailListRenderer-CCWk1BIM.js} +1 -1
- package/src/assets/web-panel/assets/{FamilyGuardDashboard-509W6W6v.js → FamilyGuardDashboard--9faIULK.js} +1 -1
- package/src/assets/web-panel/assets/{Federation-xgZReQm0.js → Federation-CejW9CgX.js} +1 -1
- package/src/assets/web-panel/assets/{FormItemContext-jZOhKcQx.js → FormItemContext-acHgcXki.js} +1 -1
- package/src/assets/web-panel/assets/{GenericCardRenderer-Jo0ULzHv.js → GenericCardRenderer-D2ghd_K4.js} +1 -1
- package/src/assets/web-panel/assets/{Git-BsRcyWQP.js → Git-BOe1YJrV.js} +2 -2
- package/src/assets/web-panel/assets/{Governance-JlpMYTAk.js → Governance-BjgzJsof.js} +1 -1
- package/src/assets/web-panel/assets/{Inference-CUEz872-.js → Inference-CXCD5rnP.js} +1 -1
- package/src/assets/web-panel/assets/{KnowledgeGraph-DgrvvEDU.js → KnowledgeGraph-CHA8QgJT.js} +1 -1
- package/src/assets/web-panel/assets/{Logs-Df-KY4U3.js → Logs-DORPMcL6.js} +2 -2
- package/src/assets/web-panel/assets/MarkdownRenderer-CPPHz01z.js +1 -0
- package/src/assets/web-panel/assets/{Marketplace-kPZDTOeI.js → Marketplace-BCFlo1Yx.js} +1 -1
- package/src/assets/web-panel/assets/{McpTools-CRRzPqNX.js → McpTools-Dl_uZAKh.js} +2 -2
- package/src/assets/web-panel/assets/{Memory-D6pNSkg9.js → Memory-DupHamwz.js} +2 -2
- package/src/assets/web-panel/assets/{MobileBridge-g-pvtwRC.js → MobileBridge-BRSMPJxW.js} +1 -1
- package/src/assets/web-panel/assets/{MobileProjects-Bzayfrfe.js → MobileProjects-BHpuzsDl.js} +1 -1
- package/src/assets/web-panel/assets/{Mtc-B7S3W-XL.js → Mtc-Ch0jOQfZ.js} +2 -2
- package/src/assets/web-panel/assets/{MtcAudit-B8OMEWVb.js → MtcAudit-qzwQrDi7.js} +2 -2
- package/src/assets/web-panel/assets/{Multisig-CowRp7GH.js → Multisig-B5wHzUcm.js} +3 -3
- package/src/assets/web-panel/assets/{NLProgramming-BkQNyi4c.js → NLProgramming-Hy9uAiA0.js} +1 -1
- package/src/assets/web-panel/assets/{Notes-Cm7gtkKz.js → Notes-DkuchMC7.js} +4 -4
- package/src/assets/web-panel/assets/{NotificationSettings-CbPTVh9m.js → NotificationSettings-CYZhD7Pi.js} +1 -1
- package/src/assets/web-panel/assets/{OrderTableRenderer-Dswi26mJ.js → OrderTableRenderer-DKOoqajt.js} +1 -1
- package/src/assets/web-panel/assets/{Organization-BkjQ9yy7.js → Organization-D9AXn6j8.js} +4 -4
- package/src/assets/web-panel/assets/{Overflow-DmRFXP7Y.js → Overflow-Bg15J6hX.js} +1 -1
- package/src/assets/web-panel/assets/{P2P-DQE1toAu.js → P2P--B54HoQn.js} +2 -2
- package/src/assets/web-panel/assets/PdhVaultBrowser-Cz9Kk2Nb.js +7 -0
- package/src/assets/web-panel/assets/{Permissions-xSjfOaiU.js → Permissions-DHx38eS1.js} +4 -4
- package/src/assets/web-panel/assets/{PersonalDataHub-B9ICLqVP.js → PersonalDataHub-Dw8i2hAs.js} +4 -4
- package/src/assets/web-panel/assets/{Pipeline-CUNwyN9E.js → Pipeline-B2o6Ur2G.js} +1 -1
- package/src/assets/web-panel/assets/{Privacy-SOoCricX.js → Privacy-8g-h2fnu.js} +1 -1
- package/src/assets/web-panel/assets/{ProjectInit-CSaFul0F.js → ProjectInit-DuVTcINx.js} +2 -2
- package/src/assets/web-panel/assets/{ProjectSettings-CueiB_oe.js → ProjectSettings-C8njvdZX.js} +2 -2
- package/src/assets/web-panel/assets/Projects-Ca9BAfu7.js +1 -0
- package/src/assets/web-panel/assets/{Providers-CTqf_31h.js → Providers-wFqgod_W.js} +1 -1
- package/src/assets/web-panel/assets/{QrScannerModal-C8ZP31E9.js → QrScannerModal-9NCXrpmA.js} +1 -1
- package/src/assets/web-panel/assets/{QuickAsk-CPgn4713.js → QuickAsk-WUTNvFkY.js} +1 -1
- package/src/assets/web-panel/assets/{Recommend-8_e0zw0i.js → Recommend-xT4nYmju.js} +1 -1
- package/src/assets/web-panel/assets/{RemoteSession-iT7lbGqw.js → RemoteSession-fak1PBGC.js} +3 -3
- package/src/assets/web-panel/assets/{Reputation-B4q9SUOz.js → Reputation-D1AkQvD-.js} +1 -1
- package/src/assets/web-panel/assets/{Row-CA_nASyL.js → Row-DVfm6xGt.js} +1 -1
- package/src/assets/web-panel/assets/{RssFeed-Cwb0E3yv.js → RssFeed-BDunoLSE.js} +3 -3
- package/src/assets/web-panel/assets/{Search-D9iVsOxJ.js → Search-OPycRoU3.js} +1 -1
- package/src/assets/web-panel/assets/{Security-DPLbYchG.js → Security-C8-Fm3Dt.js} +3 -3
- package/src/assets/web-panel/assets/{Services-DGMVL_LF.js → Services-qM_sMpMl.js} +2 -2
- package/src/assets/web-panel/assets/{Skeleton-B_6kym0D.js → Skeleton-D3HBJjVF.js} +1 -1
- package/src/assets/web-panel/assets/{Skills-Ds6r0dXB.js → Skills-jr67X-tP.js} +1 -1
- package/src/assets/web-panel/assets/{Sla-BLHAbBhb.js → Sla-CS3ggyCg.js} +1 -1
- package/src/assets/web-panel/assets/{SpeechSettings-CZuddd14.js → SpeechSettings-CTOCYxOi.js} +1 -1
- package/src/assets/web-panel/assets/{SyncSettings-CdBq7oSl.js → SyncSettings-D3s3cLT5.js} +2 -2
- package/src/assets/web-panel/assets/{Tasks-DfT60Nyl.js → Tasks-CdfoJ25m.js} +1 -1
- package/src/assets/web-panel/assets/{Templates-RLfsKy2V.js → Templates-Drb4AI8X.js} +1 -1
- package/src/assets/web-panel/assets/{Tenant-Cw8xCNLE.js → Tenant-DzwR3Qkz.js} +1 -1
- package/src/assets/web-panel/assets/{Terminal-BOUkNu4f.js → Terminal-D-ESq7xp.js} +2 -2
- package/src/assets/web-panel/assets/{TimelineRenderer-CgGZWaRm.js → TimelineRenderer-BK-AUuws.js} +1 -1
- package/src/assets/web-panel/assets/{Tokens-CpwjVyir.js → Tokens-xGTouuNp.js} +1 -1
- package/src/assets/web-panel/assets/{Trigger-DL21H-eb.js → Trigger-BvZj-v2H.js} +1 -1
- package/src/assets/web-panel/assets/{Trust-B3ffFclW.js → Trust-BLC9CGmc.js} +1 -1
- package/src/assets/web-panel/assets/{UkeySign-CkBeb5aI.js → UkeySign-BSuCXpmE.js} +1 -1
- package/src/assets/web-panel/assets/{VideoEditing-U2iVaE3r.js → VideoEditing-DT3A-_Ql.js} +1 -1
- package/src/assets/web-panel/assets/{Wallet-CQk2wzYd.js → Wallet-c1OXao85.js} +4 -4
- package/src/assets/web-panel/assets/{WebAuthn-DYIuIYzT.js → WebAuthn-CuA-F7KJ.js} +5 -5
- package/src/assets/web-panel/assets/{WorkflowEditor-tg1c2mZQ.js → WorkflowEditor-MANZrf82.js} +1 -1
- package/src/assets/web-panel/assets/{chat-CtL61spq.js → chat-D4Y4Ey0O.js} +1 -1
- package/src/assets/web-panel/assets/{colors-DKpbeo8s.js → colors-DDH06hKO.js} +1 -1
- package/src/assets/web-panel/assets/{compact-item-gPZsrswt.js → compact-item-Cym756Xj.js} +1 -1
- package/src/assets/web-panel/assets/{createContext-CfHlwzVr.js → createContext-By4Nb1k-.js} +1 -1
- package/src/assets/web-panel/assets/devWarning-KO3pAGeO.js +1 -0
- package/src/assets/web-panel/assets/{hasIn-MV47g_6X.js → hasIn-Ch7OY258.js} +1 -1
- package/src/assets/web-panel/assets/{index-DMMvlLp9.js → index-6-2eYBXr.js} +1 -1
- package/src/assets/web-panel/assets/{index-DsH-i_3d.js → index-6CpOV3Pn.js} +1 -1
- package/src/assets/web-panel/assets/{index-CB1HnbNK.js → index-ArMrnx0v.js} +1 -1
- package/src/assets/web-panel/assets/{index-DU4rZpiK.js → index-B51otVoZ.js} +1 -1
- package/src/assets/web-panel/assets/{index-CRv_uMLV.js → index-BBBUn5v7.js} +1 -1
- package/src/assets/web-panel/assets/{index-srBc9U_s.js → index-BFydNU6a.js} +1 -1
- package/src/assets/web-panel/assets/{index-DnZ_zS1M.js → index-BIrRX-ra.js} +1 -1
- package/src/assets/web-panel/assets/{index-DGtEBodr.js → index-BZB9q-Lp.js} +1 -1
- package/src/assets/web-panel/assets/index-Bh4Rgkg2.js +1 -0
- package/src/assets/web-panel/assets/{index-BjlPD7GO.js → index-Bo20BfdS.js} +1 -1
- package/src/assets/web-panel/assets/{index-C_B9LiKs.js → index-CF3Lhyns.js} +1 -1
- package/src/assets/web-panel/assets/{index-Rb7CRMw9.js → index-CGsvEbbq.js} +1 -1
- package/src/assets/web-panel/assets/{index-DIeJmlAA.js → index-CJPks33Y.js} +1 -1
- package/src/assets/web-panel/assets/{index-xUlgG0Xe.js → index-CT-3FYwd.js} +1 -1
- package/src/assets/web-panel/assets/{index-6G0ETFTW.js → index-CUSUzaB8.js} +1 -1
- package/src/assets/web-panel/assets/{index-CzfxTSPW.js → index-CV1UC4zo.js} +1 -1
- package/src/assets/web-panel/assets/{index-CmPJpLoD.js → index-CV8qXTvb.js} +1 -1
- package/src/assets/web-panel/assets/{index-fYcS7NS0.js → index-CZn6dmZe.js} +1 -1
- package/src/assets/web-panel/assets/{index-Cjv0kKCU.js → index-CjNUTBwe.js} +1 -1
- package/src/assets/web-panel/assets/{index-Df9sWKpl.js → index-CpudmDhf.js} +1 -1
- package/src/assets/web-panel/assets/{index-C9RF4ieu.js → index-CykROEJA.js} +1 -1
- package/src/assets/web-panel/assets/{index-Zvkt9xqi.js → index-D5sADPGY.js} +1 -1
- package/src/assets/web-panel/assets/{index-yxxXlqBA.js → index-D9le0-dc.js} +1 -1
- package/src/assets/web-panel/assets/{index-BBw52sEl.js → index-DAkk1M7x.js} +1 -1
- package/src/assets/web-panel/assets/{index-ExAGPAnX.js → index-DNEJzc0r.js} +1 -1
- package/src/assets/web-panel/assets/{index-BQp5M0s1.js → index-DNlxihs8.js} +1 -1
- package/src/assets/web-panel/assets/{index-Ddcpj1El.js → index-DiewptTL.js} +1 -1
- package/src/assets/web-panel/assets/{index-BOyGUuBE.js → index-DjwgJ5Jd.js} +1 -1
- package/src/assets/web-panel/assets/index-Dox_coTY.js +1 -0
- package/src/assets/web-panel/assets/{index-CmM9jqwe.js → index-DtGValNi.js} +1 -1
- package/src/assets/web-panel/assets/{index-DfLyHCWi.js → index-DvnywwUj.js} +1 -1
- package/src/assets/web-panel/assets/{index-BN-wxSQ8.js → index-OU8DZ9BL.js} +1 -1
- package/src/assets/web-panel/assets/{index-D1XB5-t5.js → index-XGeQMvWK.js} +1 -1
- package/src/assets/web-panel/assets/{index-C4lF-f_z.js → index-ZY2Qs_Sn.js} +1 -1
- package/src/assets/web-panel/assets/{index-C9gtY4nG.js → index-jx7kr6mB.js} +3 -3
- package/src/assets/web-panel/assets/{index-C6nC_DyY.js → index-mL3nOcH6.js} +1 -1
- package/src/assets/web-panel/assets/{index-Dd19vRwy.js → index-oBsfUbyH.js} +1 -1
- package/src/assets/web-panel/assets/{index-bSFrAszw.js → index-q-s04OKn.js} +1 -1
- package/src/assets/web-panel/assets/{index-D_Pl1GnW.js → index-tZ5BUdad.js} +1 -1
- package/src/assets/web-panel/assets/{initDefaultProps-BeOZoSZ4.js → initDefaultProps-DGcX9iPP.js} +1 -1
- package/src/assets/web-panel/assets/{motion-BXE8VMKx.js → motion-2SeL1D8A.js} +1 -1
- package/src/assets/web-panel/assets/{move-I9sdvPGX.js → move-3cYbQCpS.js} +1 -1
- package/src/assets/web-panel/assets/{mtc-parser-d25zrJd0.js → mtc-parser-IDdxfw0z.js} +1 -1
- package/src/assets/web-panel/assets/{omit-D71V_T4e.js → omit-BNswdKO6.js} +1 -1
- package/src/assets/web-panel/assets/{pickAttrs-BNZKpIAR.js → pickAttrs-BwCnem2I.js} +1 -1
- package/src/assets/web-panel/assets/{placementArrow-jOcgxTTz.js → placementArrow-B9PtZ5rq.js} +1 -1
- package/src/assets/web-panel/assets/{responsiveObserve-pta_O8AU.js → responsiveObserve-CPB6iSzV.js} +1 -1
- package/src/assets/web-panel/assets/{slide-Dlq6om7z.js → slide-DZgyblZa.js} +1 -1
- package/src/assets/web-panel/assets/{statusUtils-BnkO_yRv.js → statusUtils-D82IBdsx.js} +1 -1
- package/src/assets/web-panel/assets/{styleChecker-p8NfWmcG.js → styleChecker-qSHtosM-.js} +1 -1
- package/src/assets/web-panel/assets/{useFlexGapSupport-KcjZZ5U7.js → useFlexGapSupport-CmWO7h0I.js} +1 -1
- package/src/assets/web-panel/assets/{useFs-gqonDMWB.js → useFs-yLrs_ub4.js} +1 -1
- package/src/assets/web-panel/assets/{usePersonalDataHub-jIIfE4go.js → usePersonalDataHub-CJ1VW6rh.js} +1 -1
- package/src/assets/web-panel/assets/{vnode-Dyo7fBrM.js → vnode-CzY_moQ4.js} +1 -1
- package/src/assets/web-panel/assets/{zoom-6l8BMg9W.js → zoom-DAa7rEY5.js} +1 -1
- package/src/assets/web-panel/index.html +1 -1
- package/src/commands/agent.js +62 -11
- package/src/commands/learning.js +71 -1
- package/src/commands/team.js +100 -3
- package/src/data/changelog.json +29 -3
- package/src/harness/worktree-isolator.js +74 -9
- package/src/lib/agent-team/task-lease.js +12 -1
- package/src/lib/agent-team/team-graph-runtime-adapter.js +6 -0
- package/src/lib/agent-team/team-runner.js +4 -0
- package/src/lib/agent-team/team-worktree.js +4 -0
- package/src/lib/capability-manifest.js +212 -1
- package/src/lib/evolution/evolution-artifact-ports.js +3277 -0
- package/src/lib/evolution/evolution-eval-gate.js +6442 -0
- package/src/lib/evolution/evolution-evidence-projector.js +3786 -0
- package/src/lib/evolution/evolution-ledger-ports.js +2689 -0
- package/src/lib/evolution/evolution-ledger.js +4280 -0
- package/src/lib/evolution/skill-candidate-registry.js +2258 -0
- package/src/lib/evolution/skill-execution-manifest.js +942 -0
- package/src/lib/evolution/skill-mutation-authority.js +1677 -0
- package/src/lib/evolution/skill-promotion-controller.js +506 -0
- package/src/lib/evolution/skill-release-registry.js +3277 -0
- package/src/lib/evolution/skill-target-matrix-eval.js +3233 -0
- package/src/lib/evolution/skill-writer-inventory-manifest.js +998 -0
- package/src/lib/evolution/skill-writer-inventory-validator.js +816 -0
- package/src/lib/formal-quality-eval-runtime.js +192 -0
- package/src/lib/graph-kernel/cutover-contract.js +7 -0
- package/src/lib/graph-kernel/cutover-ledger.js +8 -7
- package/src/lib/graph-kernel/evidence-digest.js +47 -0
- package/src/lib/graph-kernel/graph-runtime-surfaces.json +8 -1
- package/src/lib/graph-kernel/production-cutover-evidence.js +342 -15
- package/src/lib/graph-kernel/production-source-evidence.js +1599 -0
- package/src/lib/graph-kernel/retirement-evidence.js +15 -5
- package/src/lib/graph-kernel/runtime-surface-manifest.js +67 -2
- package/src/lib/headless-manifest.js +3 -1
- package/src/lib/learning/skill-improver.js +311 -43
- package/src/lib/learning/skill-synthesizer.js +236 -16
- package/src/lib/process-execution-broker/index.js +32 -0
- package/src/lib/process-execution-broker/platform-sandbox.js +5 -4
- package/src/lib/secure-fs.js +45 -2
- package/src/runtime/agent-core.js +17 -5
- package/src/runtime/headless-runner.js +16 -9
- package/src/assets/web-panel/assets/MarkdownRenderer-BEPDcSwz.js +0 -1
- package/src/assets/web-panel/assets/PdhVaultBrowser-BujyoDRG.js +0 -7
- package/src/assets/web-panel/assets/Projects-BOeXvU7y.js +0 -1
- package/src/assets/web-panel/assets/devWarning-DdqnMhzi.js +0 -1
- package/src/assets/web-panel/assets/index-BcK8o4ke.js +0 -1
- package/src/assets/web-panel/assets/index-DvbAjyYI.js +0 -1
|
@@ -0,0 +1,3277 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { types as utilTypes } from "node:util";
|
|
6
|
+
import { getHomeDir } from "../paths.js";
|
|
7
|
+
import { ensurePrivateDirectory, ensurePrivateFile } from "../secure-fs.js";
|
|
8
|
+
import { verifySkillCandidateDraft } from "./skill-candidate-registry.js";
|
|
9
|
+
import {
|
|
10
|
+
SKILL_MUTATION_OPERATIONS,
|
|
11
|
+
SKILL_MUTATION_RECEIPT_KINDS,
|
|
12
|
+
SKILL_MUTATION_ROLES,
|
|
13
|
+
SKILL_MUTATION_TARGET_SCOPES,
|
|
14
|
+
digestSkillMutationDependencyLock,
|
|
15
|
+
digestSkillMutationReceiptEnvelope,
|
|
16
|
+
digestSkillMutationTransitionSubject,
|
|
17
|
+
verifySkillMutationConsumptionReceipt,
|
|
18
|
+
verifySkillMutationRequest,
|
|
19
|
+
} from "./skill-mutation-authority.js";
|
|
20
|
+
import { consumeRegistryTransitionCapability } from "./skill-promotion-controller.js";
|
|
21
|
+
|
|
22
|
+
export const SKILL_RELEASE_SCHEMA = "chainlesschain.skill-release/v4";
|
|
23
|
+
export const SKILL_RELEASE_STATE_SCHEMA =
|
|
24
|
+
"chainlesschain.skill-release-state/v3";
|
|
25
|
+
export const SKILL_RELEASE_RECEIPT_SCHEMA =
|
|
26
|
+
"chainlesschain.skill-release-transition-receipt/v4";
|
|
27
|
+
export const SKILL_RELEASE_LEDGER_PROJECTION_SCHEMA =
|
|
28
|
+
"chainlesschain.skill-release-ledger-projection/v3";
|
|
29
|
+
export const SKILL_RELEASE_TENANT_MARKER_SCHEMA =
|
|
30
|
+
"chainlesschain.skill-release-tenant-marker/v1";
|
|
31
|
+
export const SKILL_RELEASE_MIGRATION_REQUIRED_CODE =
|
|
32
|
+
"SKILL_RELEASE_MIGRATION_REQUIRED";
|
|
33
|
+
|
|
34
|
+
const JOURNAL_SCHEMA = "chainlesschain.skill-release-journal/v4";
|
|
35
|
+
const INTENT_SCHEMA = "chainlesschain.skill-release-transition-intent/v2";
|
|
36
|
+
const RELEASE_DOMAIN = `${SKILL_RELEASE_SCHEMA}\0`;
|
|
37
|
+
const STATE_DOMAIN = `${SKILL_RELEASE_STATE_SCHEMA}\0`;
|
|
38
|
+
const RECEIPT_DOMAIN = `${SKILL_RELEASE_RECEIPT_SCHEMA}\0`;
|
|
39
|
+
const JOURNAL_DOMAIN = `${JOURNAL_SCHEMA}\0`;
|
|
40
|
+
const INTENT_DOMAIN = `${INTENT_SCHEMA}\0`;
|
|
41
|
+
const EMPTY_ACTIVE_DOMAIN = "chainlesschain.skill-active/empty/v1\0";
|
|
42
|
+
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
|
|
43
|
+
const SKILL_NAME_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
|
44
|
+
const TOKEN_PATTERN = /^[a-f0-9]{32}$/u;
|
|
45
|
+
const LEDGER_EPOCH_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$/u;
|
|
46
|
+
const TENANT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/u;
|
|
47
|
+
const TENANT_KEY_PATTERN = /^[a-f0-9]{64}$/u;
|
|
48
|
+
const TEMP_PATTERN = /^\.(?:release|state|write)-[A-Za-z0-9._-]+\.tmp$/u;
|
|
49
|
+
const MAX_FILE_BYTES = 4 * 1024 * 1024;
|
|
50
|
+
const MAX_TENANT_MARKER_BYTES = 4096;
|
|
51
|
+
const MAX_CANONICAL_DEPTH = 32;
|
|
52
|
+
const MAX_CANONICAL_NODES = 100_000;
|
|
53
|
+
const MAX_CANONICAL_ARRAY_ENTRIES = 65_536;
|
|
54
|
+
const MAX_CANONICAL_OBJECT_FIELDS = 4096;
|
|
55
|
+
const MIN_LEASE_TTL_MS = 25;
|
|
56
|
+
const DEFAULT_LEASE_TTL_MS = 30_000;
|
|
57
|
+
const TENANT_KEY_DOMAIN = "chainlesschain.skill-release-tenant-key/v1";
|
|
58
|
+
const TENANT_MARKER_DIGEST_DOMAIN =
|
|
59
|
+
"chainlesschain.skill-release-tenant-marker/v1\0";
|
|
60
|
+
const TENANT_MARKER_COMPONENT = "skill-release-registry";
|
|
61
|
+
const TENANT_MARKER_FILE = "_tenant.json";
|
|
62
|
+
const LEGACY_RELEASE_SCHEMA = "chainlesschain.skill-release/v3";
|
|
63
|
+
const LEGACY_STATE_SCHEMA = "chainlesschain.skill-release-state/v2";
|
|
64
|
+
const LEGACY_JOURNAL_SCHEMA = "chainlesschain.skill-release-journal/v3";
|
|
65
|
+
const LEGACY_LOCK_OWNER_SCHEMA = "chainlesschain.skill-release-lock-owner/v1";
|
|
66
|
+
const LOCK_OWNER_SCHEMA = "chainlesschain.skill-release-lock-owner/v2";
|
|
67
|
+
|
|
68
|
+
export const EMPTY_SKILL_ACTIVE_DIGEST = sha256(
|
|
69
|
+
Buffer.from(EMPTY_ACTIVE_DOMAIN, "utf8"),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
export class SkillReleaseRegistryError extends Error {
|
|
73
|
+
constructor(code, message, details = {}) {
|
|
74
|
+
super(message, details.cause ? { cause: details.cause } : undefined);
|
|
75
|
+
this.name = "SkillReleaseRegistryError";
|
|
76
|
+
this.code = code;
|
|
77
|
+
for (const [key, value] of Object.entries(details)) {
|
|
78
|
+
if (key !== "cause") this[key] = value;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class SkillReleaseSimulatedCrashError extends Error {
|
|
84
|
+
constructor(phase) {
|
|
85
|
+
super(`simulated hard crash at ${phase}`);
|
|
86
|
+
this.name = "SkillReleaseSimulatedCrashError";
|
|
87
|
+
this.phase = phase;
|
|
88
|
+
this.preserveForRecovery = true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function failure(code, message, details = {}) {
|
|
93
|
+
return new SkillReleaseRegistryError(code, message, details);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function migrationRequired(message, details = {}) {
|
|
97
|
+
return failure(SKILL_RELEASE_MIGRATION_REQUIRED_CODE, message, details);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isPlainObject(value) {
|
|
101
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
102
|
+
const prototype = Object.getPrototypeOf(value);
|
|
103
|
+
return prototype === Object.prototype || prototype === null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function assertExactKeys(
|
|
107
|
+
value,
|
|
108
|
+
expected,
|
|
109
|
+
label,
|
|
110
|
+
code = "SKILL_RELEASE_INVALID",
|
|
111
|
+
) {
|
|
112
|
+
if (value && typeof value === "object" && utilTypes.isProxy(value)) {
|
|
113
|
+
throw failure(code, `${label} must not be a Proxy`);
|
|
114
|
+
}
|
|
115
|
+
if (!isPlainObject(value)) throw failure(code, `${label} must be an object`);
|
|
116
|
+
const keys = Reflect.ownKeys(value);
|
|
117
|
+
if (
|
|
118
|
+
keys.length !== expected.size ||
|
|
119
|
+
keys.some((key) => typeof key !== "string" || !expected.has(key))
|
|
120
|
+
) {
|
|
121
|
+
throw failure(code, `${label} must contain exactly the supported fields`);
|
|
122
|
+
}
|
|
123
|
+
for (const key of keys) {
|
|
124
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
125
|
+
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
|
|
126
|
+
throw failure(code, `${label}.${String(key)} must be an own data field`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function canonicalJson(value) {
|
|
132
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
133
|
+
if (Array.isArray(value)) {
|
|
134
|
+
return `[${value.map((entry) => canonicalJson(entry)).join(",")}]`;
|
|
135
|
+
}
|
|
136
|
+
return `{${Object.keys(value)
|
|
137
|
+
.sort()
|
|
138
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
|
|
139
|
+
.join(",")}}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function sha256(value) {
|
|
143
|
+
return `sha256:${crypto.createHash("sha256").update(value).digest("hex")}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function domainDigest(domain, value) {
|
|
147
|
+
return sha256(
|
|
148
|
+
Buffer.concat([
|
|
149
|
+
Buffer.from(domain, "utf8"),
|
|
150
|
+
Buffer.from(canonicalJson(value), "utf8"),
|
|
151
|
+
]),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function deepFreeze(value, seen = new WeakSet()) {
|
|
156
|
+
if (!value || typeof value !== "object" || seen.has(value)) return value;
|
|
157
|
+
seen.add(value);
|
|
158
|
+
for (const key of Reflect.ownKeys(value)) deepFreeze(value[key], seen);
|
|
159
|
+
return Object.freeze(value);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function serialize(value) {
|
|
163
|
+
const bytes = Buffer.from(`${canonicalJson(value)}\n`, "utf8");
|
|
164
|
+
if (bytes.length < 1 || bytes.length > MAX_FILE_BYTES) {
|
|
165
|
+
throw failure("SKILL_RELEASE_INVALID", "artifact exceeds its size limit");
|
|
166
|
+
}
|
|
167
|
+
return bytes;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function digest(value, label, { nullable = false } = {}) {
|
|
171
|
+
if (nullable && value === null) return value;
|
|
172
|
+
if (typeof value !== "string" || !DIGEST_PATTERN.test(value)) {
|
|
173
|
+
throw failure(
|
|
174
|
+
"SKILL_RELEASE_INVALID",
|
|
175
|
+
`${label} must be a lowercase sha256 digest${nullable ? " or null" : ""}`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return value;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function skillName(value) {
|
|
182
|
+
if (typeof value !== "string" || !SKILL_NAME_PATTERN.test(value)) {
|
|
183
|
+
throw failure("SKILL_RELEASE_INVALID", "skillName must use kebab-case");
|
|
184
|
+
}
|
|
185
|
+
return value;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function safeInteger(value, label, { minimum = 0 } = {}) {
|
|
189
|
+
if (!Number.isSafeInteger(value) || Object.is(value, -0) || value < minimum) {
|
|
190
|
+
throw failure("SKILL_RELEASE_INVALID", `${label} must be a safe integer`);
|
|
191
|
+
}
|
|
192
|
+
return value;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function boundedString(value, label, maximum = 256) {
|
|
196
|
+
if (
|
|
197
|
+
typeof value !== "string" ||
|
|
198
|
+
value.length < 1 ||
|
|
199
|
+
value.length > maximum ||
|
|
200
|
+
value.trim() !== value ||
|
|
201
|
+
value.includes("\0")
|
|
202
|
+
) {
|
|
203
|
+
throw failure("SKILL_RELEASE_INVALID", `${label} is invalid`);
|
|
204
|
+
}
|
|
205
|
+
return value;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function normalizeJson(value, label, depth = 0) {
|
|
209
|
+
if (depth > 20)
|
|
210
|
+
throw failure("SKILL_RELEASE_INVALID", `${label} is too deep`);
|
|
211
|
+
if (value === null || typeof value === "boolean") return value;
|
|
212
|
+
if (typeof value === "string") return boundedString(value, label, 16_384);
|
|
213
|
+
if (typeof value === "number") return safeInteger(value, label);
|
|
214
|
+
if (value && typeof value === "object" && utilTypes.isProxy(value)) {
|
|
215
|
+
throw failure("SKILL_RELEASE_INVALID", `${label} must not be a Proxy`);
|
|
216
|
+
}
|
|
217
|
+
if (Array.isArray(value)) {
|
|
218
|
+
const keys = Reflect.ownKeys(value);
|
|
219
|
+
if (
|
|
220
|
+
value.length > 2_048 ||
|
|
221
|
+
keys.length !== value.length + 1 ||
|
|
222
|
+
keys.some(
|
|
223
|
+
(key) =>
|
|
224
|
+
typeof key !== "string" ||
|
|
225
|
+
(key !== "length" && !/^(?:0|[1-9]\d*)$/u.test(key)),
|
|
226
|
+
)
|
|
227
|
+
) {
|
|
228
|
+
throw failure("SKILL_RELEASE_INVALID", `${label} has too many entries`);
|
|
229
|
+
}
|
|
230
|
+
const output = [];
|
|
231
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
232
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
233
|
+
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
|
|
234
|
+
throw failure(
|
|
235
|
+
"SKILL_RELEASE_INVALID",
|
|
236
|
+
`${label}[${index}] must be an own data field`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
output.push(
|
|
240
|
+
normalizeJson(descriptor.value, `${label}[${index}]`, depth + 1),
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
return output;
|
|
244
|
+
}
|
|
245
|
+
if (!isPlainObject(value)) {
|
|
246
|
+
throw failure("SKILL_RELEASE_INVALID", `${label} must be canonical JSON`);
|
|
247
|
+
}
|
|
248
|
+
const keys = Reflect.ownKeys(value);
|
|
249
|
+
if (
|
|
250
|
+
keys.length > 2_048 ||
|
|
251
|
+
keys.some((key) => typeof key !== "string" || !key || key.length > 256)
|
|
252
|
+
) {
|
|
253
|
+
throw failure("SKILL_RELEASE_INVALID", `${label} has unsafe keys`);
|
|
254
|
+
}
|
|
255
|
+
return Object.fromEntries(
|
|
256
|
+
keys.sort().map((key) => {
|
|
257
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
258
|
+
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
|
|
259
|
+
throw failure(
|
|
260
|
+
"SKILL_RELEASE_INVALID",
|
|
261
|
+
`${label}.${key} must be an own data field`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
return [
|
|
265
|
+
key,
|
|
266
|
+
normalizeJson(descriptor.value, `${label}.${key}`, depth + 1),
|
|
267
|
+
];
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function assertBoundedCanonicalPreflight(value, code) {
|
|
273
|
+
const stack = [{ depth: 0, value }];
|
|
274
|
+
let nodes = 0;
|
|
275
|
+
let utf8Bytes = 0;
|
|
276
|
+
while (stack.length > 0) {
|
|
277
|
+
const current = stack.pop();
|
|
278
|
+
nodes += 1;
|
|
279
|
+
if (nodes > MAX_CANONICAL_NODES || current.depth > MAX_CANONICAL_DEPTH) {
|
|
280
|
+
throw failure(code, "artifact exceeds the canonical structure budget");
|
|
281
|
+
}
|
|
282
|
+
const currentValue = current.value;
|
|
283
|
+
if (typeof currentValue === "string") {
|
|
284
|
+
utf8Bytes += Buffer.byteLength(currentValue, "utf8");
|
|
285
|
+
} else if (typeof currentValue === "number") {
|
|
286
|
+
if (!Number.isFinite(currentValue) || Object.is(currentValue, -0)) {
|
|
287
|
+
throw failure(code, "artifact contains a non-canonical number");
|
|
288
|
+
}
|
|
289
|
+
} else if (currentValue !== null && typeof currentValue === "object") {
|
|
290
|
+
if (Array.isArray(currentValue)) {
|
|
291
|
+
if (currentValue.length > MAX_CANONICAL_ARRAY_ENTRIES) {
|
|
292
|
+
throw failure(code, "artifact array exceeds its entry budget");
|
|
293
|
+
}
|
|
294
|
+
for (let index = currentValue.length - 1; index >= 0; index -= 1) {
|
|
295
|
+
stack.push({
|
|
296
|
+
depth: current.depth + 1,
|
|
297
|
+
value: currentValue[index],
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
} else {
|
|
301
|
+
const keys = Object.keys(currentValue);
|
|
302
|
+
if (keys.length > MAX_CANONICAL_OBJECT_FIELDS) {
|
|
303
|
+
throw failure(code, "artifact object exceeds its field budget");
|
|
304
|
+
}
|
|
305
|
+
for (let index = keys.length - 1; index >= 0; index -= 1) {
|
|
306
|
+
const key = keys[index];
|
|
307
|
+
utf8Bytes += Buffer.byteLength(key, "utf8");
|
|
308
|
+
stack.push({
|
|
309
|
+
depth: current.depth + 1,
|
|
310
|
+
value: currentValue[key],
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (utf8Bytes > MAX_FILE_BYTES) {
|
|
316
|
+
throw failure(code, "artifact exceeds its canonical UTF-8 budget");
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function tenantId(value, label = "tenantId") {
|
|
322
|
+
const normalized = boundedString(value, label, 256);
|
|
323
|
+
if (!TENANT_ID_PATTERN.test(normalized)) {
|
|
324
|
+
throw failure("SKILL_RELEASE_INVALID", `${label} is invalid`);
|
|
325
|
+
}
|
|
326
|
+
return normalized;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function deriveSkillReleaseTenantKey(value) {
|
|
330
|
+
const normalized = tenantId(value);
|
|
331
|
+
return crypto
|
|
332
|
+
.createHash("sha256")
|
|
333
|
+
.update(TENANT_KEY_DOMAIN, "utf8")
|
|
334
|
+
.update("\0", "utf8")
|
|
335
|
+
.update(normalized, "utf8")
|
|
336
|
+
.digest("hex");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function normalizeReceiptDigests(value) {
|
|
340
|
+
const expected = new Set(SKILL_MUTATION_RECEIPT_KINDS);
|
|
341
|
+
assertExactKeys(value, expected, "receiptDigests");
|
|
342
|
+
return deepFreeze(
|
|
343
|
+
Object.fromEntries(
|
|
344
|
+
SKILL_MUTATION_RECEIPT_KINDS.map((kind) => [
|
|
345
|
+
kind,
|
|
346
|
+
digest(value[kind], `receiptDigests.${kind}`),
|
|
347
|
+
]),
|
|
348
|
+
),
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function receiptDigestsFromRequest(request) {
|
|
353
|
+
return deepFreeze(
|
|
354
|
+
Object.fromEntries(
|
|
355
|
+
SKILL_MUTATION_RECEIPT_KINDS.map((kind) => [
|
|
356
|
+
kind,
|
|
357
|
+
digestSkillMutationReceiptEnvelope(request.receipts[`${kind}Receipt`]),
|
|
358
|
+
]),
|
|
359
|
+
),
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function digestDependencyLock(value) {
|
|
364
|
+
const normalized = normalizeJson(value, "dependencyLock");
|
|
365
|
+
if (!isPlainObject(normalized)) {
|
|
366
|
+
throw failure("SKILL_RELEASE_INVALID", "dependencyLock must be an object");
|
|
367
|
+
}
|
|
368
|
+
return digestSkillMutationDependencyLock(normalized);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function verifyRequestReceiptBinding(request, receipt) {
|
|
372
|
+
for (const field of [
|
|
373
|
+
"tenantId",
|
|
374
|
+
"audience",
|
|
375
|
+
"operationId",
|
|
376
|
+
"operation",
|
|
377
|
+
"transitionSubjectDigest",
|
|
378
|
+
"skillName",
|
|
379
|
+
"targetScope",
|
|
380
|
+
"expectedTargetDigest",
|
|
381
|
+
"expectedTargetRevision",
|
|
382
|
+
"expiresAt",
|
|
383
|
+
"nonce",
|
|
384
|
+
"requestDigest",
|
|
385
|
+
]) {
|
|
386
|
+
if (receipt[field] !== request[field]) {
|
|
387
|
+
throw failure(
|
|
388
|
+
"SKILL_RELEASE_AUTHORITY_INVALID",
|
|
389
|
+
`consumption receipt is not bound to request ${field}`,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (
|
|
394
|
+
request.targetScope !== SKILL_MUTATION_TARGET_SCOPES.ACTIVE ||
|
|
395
|
+
receipt.role !== SKILL_MUTATION_ROLES.PROMOTION_CONTROLLER ||
|
|
396
|
+
![
|
|
397
|
+
SKILL_MUTATION_OPERATIONS.PROMOTE,
|
|
398
|
+
SKILL_MUTATION_OPERATIONS.ROLLBACK,
|
|
399
|
+
].includes(request.operation)
|
|
400
|
+
) {
|
|
401
|
+
throw failure(
|
|
402
|
+
"SKILL_RELEASE_AUTHORITY_INVALID",
|
|
403
|
+
"release requires an active promotion-controller receipt",
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const RELEASE_KEYS = new Set([
|
|
409
|
+
"authorityReceiptDigest",
|
|
410
|
+
"candidate",
|
|
411
|
+
"candidateId",
|
|
412
|
+
"contentDigest",
|
|
413
|
+
"dependencyLock",
|
|
414
|
+
"dependencyLockDigest",
|
|
415
|
+
"mutationRequestDigest",
|
|
416
|
+
"parentDigest",
|
|
417
|
+
"receiptDigests",
|
|
418
|
+
"releaseDigest",
|
|
419
|
+
"runtimeManifest",
|
|
420
|
+
"runtimeManifestDigest",
|
|
421
|
+
"schema",
|
|
422
|
+
"skillName",
|
|
423
|
+
"targetMatrix",
|
|
424
|
+
"targetMatrixRoot",
|
|
425
|
+
"targetRuntimes",
|
|
426
|
+
"tenantId",
|
|
427
|
+
"transitionSubjectDigest",
|
|
428
|
+
]);
|
|
429
|
+
const BUILD_RELEASE_INPUT_KEYS = new Set([
|
|
430
|
+
"candidate",
|
|
431
|
+
"consumptionReceipt",
|
|
432
|
+
"mutationRequest",
|
|
433
|
+
]);
|
|
434
|
+
|
|
435
|
+
export function buildSkillRelease(input) {
|
|
436
|
+
assertExactKeys(input, BUILD_RELEASE_INPUT_KEYS, "release build input");
|
|
437
|
+
const verifiedCandidate = verifySkillCandidateDraft(input.candidate);
|
|
438
|
+
const request = verifySkillMutationRequest(input.mutationRequest);
|
|
439
|
+
const authorityReceipt = verifySkillMutationConsumptionReceipt(
|
|
440
|
+
input.consumptionReceipt,
|
|
441
|
+
);
|
|
442
|
+
verifyRequestReceiptBinding(request, authorityReceipt);
|
|
443
|
+
if (
|
|
444
|
+
request.operation !== SKILL_MUTATION_OPERATIONS.PROMOTE ||
|
|
445
|
+
authorityReceipt.operation !== SKILL_MUTATION_OPERATIONS.PROMOTE ||
|
|
446
|
+
request.skillName !== verifiedCandidate.skillName ||
|
|
447
|
+
request.tenantId !== verifiedCandidate.tenantId ||
|
|
448
|
+
authorityReceipt.tenantId !== verifiedCandidate.tenantId
|
|
449
|
+
) {
|
|
450
|
+
throw failure(
|
|
451
|
+
"SKILL_RELEASE_INVALID",
|
|
452
|
+
"release creation requires a promotion request for the tenant-bound candidate Skill",
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
const expectedParent =
|
|
456
|
+
request.expectedTargetDigest === EMPTY_SKILL_ACTIVE_DIGEST
|
|
457
|
+
? null
|
|
458
|
+
: request.expectedTargetDigest;
|
|
459
|
+
if (verifiedCandidate.parentDigest !== expectedParent) {
|
|
460
|
+
throw failure(
|
|
461
|
+
"SKILL_RELEASE_INVALID",
|
|
462
|
+
"candidate parent is not the authorized CAS target",
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
const dependencyLockDigest = verifiedCandidate.dependencyLockDigest;
|
|
466
|
+
const transitionSubjectDigest = digestSkillMutationTransitionSubject({
|
|
467
|
+
tenantId: request.tenantId,
|
|
468
|
+
skillName: verifiedCandidate.skillName,
|
|
469
|
+
operation: SKILL_MUTATION_OPERATIONS.PROMOTE,
|
|
470
|
+
candidateId: verifiedCandidate.candidateId,
|
|
471
|
+
rollbackTargetReleaseDigest: null,
|
|
472
|
+
dependencyLockDigest,
|
|
473
|
+
expectedActiveContentDigest: request.expectedTargetDigest,
|
|
474
|
+
expectedActiveRevision: request.expectedTargetRevision,
|
|
475
|
+
});
|
|
476
|
+
if (
|
|
477
|
+
request.transitionSubjectDigest !== transitionSubjectDigest ||
|
|
478
|
+
authorityReceipt.transitionSubjectDigest !== transitionSubjectDigest
|
|
479
|
+
) {
|
|
480
|
+
throw failure(
|
|
481
|
+
"SKILL_RELEASE_AUTHORITY_INVALID",
|
|
482
|
+
"promotion request is not bound to the candidate and dependency lock",
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
const core = {
|
|
486
|
+
authorityReceiptDigest: authorityReceipt.receiptDigest,
|
|
487
|
+
candidate: verifiedCandidate,
|
|
488
|
+
candidateId: verifiedCandidate.candidateId,
|
|
489
|
+
contentDigest: verifiedCandidate.contentDigest,
|
|
490
|
+
dependencyLock: verifiedCandidate.dependencyLock,
|
|
491
|
+
dependencyLockDigest,
|
|
492
|
+
mutationRequestDigest: request.requestDigest,
|
|
493
|
+
parentDigest: verifiedCandidate.parentDigest,
|
|
494
|
+
receiptDigests: receiptDigestsFromRequest(request),
|
|
495
|
+
runtimeManifest: verifiedCandidate.runtimeManifest,
|
|
496
|
+
runtimeManifestDigest: verifiedCandidate.runtimeManifestDigest,
|
|
497
|
+
schema: SKILL_RELEASE_SCHEMA,
|
|
498
|
+
skillName: verifiedCandidate.skillName,
|
|
499
|
+
targetMatrix: verifiedCandidate.targetMatrix,
|
|
500
|
+
targetMatrixRoot: verifiedCandidate.targetMatrixRoot,
|
|
501
|
+
targetRuntimes: [...verifiedCandidate.targetRuntimes],
|
|
502
|
+
tenantId: verifiedCandidate.tenantId,
|
|
503
|
+
transitionSubjectDigest,
|
|
504
|
+
};
|
|
505
|
+
return deepFreeze({
|
|
506
|
+
...core,
|
|
507
|
+
releaseDigest: domainDigest(RELEASE_DOMAIN, core),
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export function verifySkillRelease(value) {
|
|
512
|
+
if (
|
|
513
|
+
value &&
|
|
514
|
+
typeof value === "object" &&
|
|
515
|
+
!utilTypes.isProxy(value) &&
|
|
516
|
+
Object.getOwnPropertyDescriptor(value, "schema")?.value ===
|
|
517
|
+
LEGACY_RELEASE_SCHEMA
|
|
518
|
+
) {
|
|
519
|
+
throw migrationRequired(
|
|
520
|
+
"legacy SkillRelease v3 requires explicit tenant-scoped migration",
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
assertExactKeys(value, RELEASE_KEYS, "release");
|
|
524
|
+
if (value.schema !== SKILL_RELEASE_SCHEMA) {
|
|
525
|
+
throw failure("SKILL_RELEASE_INVALID", "release schema is invalid");
|
|
526
|
+
}
|
|
527
|
+
const candidate = verifySkillCandidateDraft(value.candidate);
|
|
528
|
+
const core = {
|
|
529
|
+
authorityReceiptDigest: digest(
|
|
530
|
+
value.authorityReceiptDigest,
|
|
531
|
+
"authorityReceiptDigest",
|
|
532
|
+
),
|
|
533
|
+
candidate,
|
|
534
|
+
candidateId: digest(value.candidateId, "candidateId"),
|
|
535
|
+
contentDigest: digest(value.contentDigest, "contentDigest"),
|
|
536
|
+
dependencyLock: normalizeJson(value.dependencyLock, "dependencyLock"),
|
|
537
|
+
dependencyLockDigest: digest(
|
|
538
|
+
value.dependencyLockDigest,
|
|
539
|
+
"dependencyLockDigest",
|
|
540
|
+
),
|
|
541
|
+
mutationRequestDigest: digest(
|
|
542
|
+
value.mutationRequestDigest,
|
|
543
|
+
"mutationRequestDigest",
|
|
544
|
+
),
|
|
545
|
+
parentDigest: digest(value.parentDigest, "parentDigest", {
|
|
546
|
+
nullable: true,
|
|
547
|
+
}),
|
|
548
|
+
receiptDigests: normalizeReceiptDigests(value.receiptDigests),
|
|
549
|
+
runtimeManifest: normalizeJson(value.runtimeManifest, "runtimeManifest"),
|
|
550
|
+
runtimeManifestDigest: digest(
|
|
551
|
+
value.runtimeManifestDigest,
|
|
552
|
+
"runtimeManifestDigest",
|
|
553
|
+
),
|
|
554
|
+
schema: SKILL_RELEASE_SCHEMA,
|
|
555
|
+
skillName: skillName(value.skillName),
|
|
556
|
+
targetMatrix: normalizeJson(value.targetMatrix, "targetMatrix"),
|
|
557
|
+
targetMatrixRoot: digest(value.targetMatrixRoot, "targetMatrixRoot"),
|
|
558
|
+
targetRuntimes: normalizeJson(value.targetRuntimes, "targetRuntimes"),
|
|
559
|
+
tenantId: tenantId(value.tenantId),
|
|
560
|
+
transitionSubjectDigest: digest(
|
|
561
|
+
value.transitionSubjectDigest,
|
|
562
|
+
"transitionSubjectDigest",
|
|
563
|
+
),
|
|
564
|
+
};
|
|
565
|
+
if (
|
|
566
|
+
!Array.isArray(core.targetRuntimes) ||
|
|
567
|
+
core.tenantId !== candidate.tenantId ||
|
|
568
|
+
core.skillName !== candidate.skillName ||
|
|
569
|
+
core.candidateId !== candidate.candidateId ||
|
|
570
|
+
core.parentDigest !== candidate.parentDigest ||
|
|
571
|
+
core.contentDigest !== candidate.contentDigest ||
|
|
572
|
+
core.dependencyLockDigest !== candidate.dependencyLockDigest ||
|
|
573
|
+
core.runtimeManifestDigest !== candidate.runtimeManifestDigest ||
|
|
574
|
+
core.targetMatrixRoot !== candidate.targetMatrixRoot ||
|
|
575
|
+
canonicalJson(core.dependencyLock) !==
|
|
576
|
+
canonicalJson(candidate.dependencyLock) ||
|
|
577
|
+
canonicalJson(core.runtimeManifest) !==
|
|
578
|
+
canonicalJson(candidate.runtimeManifest) ||
|
|
579
|
+
canonicalJson(core.targetMatrix) !==
|
|
580
|
+
canonicalJson(candidate.targetMatrix) ||
|
|
581
|
+
canonicalJson(core.targetRuntimes) !==
|
|
582
|
+
canonicalJson(candidate.targetRuntimes) ||
|
|
583
|
+
digest(value.releaseDigest, "releaseDigest") !==
|
|
584
|
+
domainDigest(RELEASE_DOMAIN, core)
|
|
585
|
+
) {
|
|
586
|
+
throw failure(
|
|
587
|
+
"SKILL_RELEASE_INVALID",
|
|
588
|
+
"release digest verification failed",
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
return deepFreeze({ ...core, releaseDigest: value.releaseDigest });
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const STATE_KEYS = new Set([
|
|
595
|
+
"activeReleaseDigest",
|
|
596
|
+
"authorityReceiptDigest",
|
|
597
|
+
"dependencyLockDigest",
|
|
598
|
+
"fence",
|
|
599
|
+
"lastKnownGoodReleaseDigest",
|
|
600
|
+
"revision",
|
|
601
|
+
"schema",
|
|
602
|
+
"skillName",
|
|
603
|
+
"stateDigest",
|
|
604
|
+
"tenantId",
|
|
605
|
+
"transactionId",
|
|
606
|
+
]);
|
|
607
|
+
|
|
608
|
+
function buildState(input) {
|
|
609
|
+
const core = {
|
|
610
|
+
activeReleaseDigest: input.activeReleaseDigest,
|
|
611
|
+
authorityReceiptDigest: input.authorityReceiptDigest,
|
|
612
|
+
dependencyLockDigest: input.dependencyLockDigest,
|
|
613
|
+
fence: input.fence,
|
|
614
|
+
lastKnownGoodReleaseDigest: input.lastKnownGoodReleaseDigest,
|
|
615
|
+
revision: input.revision,
|
|
616
|
+
schema: SKILL_RELEASE_STATE_SCHEMA,
|
|
617
|
+
skillName: input.skillName,
|
|
618
|
+
tenantId: input.tenantId,
|
|
619
|
+
transactionId: input.transactionId,
|
|
620
|
+
};
|
|
621
|
+
return deepFreeze({ ...core, stateDigest: domainDigest(STATE_DOMAIN, core) });
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function initialState(name, ownerTenantId) {
|
|
625
|
+
return buildState({
|
|
626
|
+
activeReleaseDigest: null,
|
|
627
|
+
authorityReceiptDigest: null,
|
|
628
|
+
dependencyLockDigest: null,
|
|
629
|
+
fence: 0,
|
|
630
|
+
lastKnownGoodReleaseDigest: null,
|
|
631
|
+
revision: 0,
|
|
632
|
+
skillName: name,
|
|
633
|
+
tenantId: ownerTenantId,
|
|
634
|
+
transactionId: null,
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function verifyState(value) {
|
|
639
|
+
if (
|
|
640
|
+
value &&
|
|
641
|
+
typeof value === "object" &&
|
|
642
|
+
!utilTypes.isProxy(value) &&
|
|
643
|
+
Object.getOwnPropertyDescriptor(value, "schema")?.value ===
|
|
644
|
+
LEGACY_STATE_SCHEMA
|
|
645
|
+
) {
|
|
646
|
+
throw migrationRequired(
|
|
647
|
+
"legacy Skill release state requires explicit tenant-scoped migration",
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
assertExactKeys(
|
|
651
|
+
value,
|
|
652
|
+
STATE_KEYS,
|
|
653
|
+
"release state",
|
|
654
|
+
"SKILL_RELEASE_STATE_CORRUPT",
|
|
655
|
+
);
|
|
656
|
+
if (value.schema !== SKILL_RELEASE_STATE_SCHEMA) {
|
|
657
|
+
throw failure("SKILL_RELEASE_STATE_CORRUPT", "state schema is invalid");
|
|
658
|
+
}
|
|
659
|
+
const normalized = buildState({
|
|
660
|
+
activeReleaseDigest: digest(
|
|
661
|
+
value.activeReleaseDigest,
|
|
662
|
+
"activeReleaseDigest",
|
|
663
|
+
),
|
|
664
|
+
authorityReceiptDigest: digest(
|
|
665
|
+
value.authorityReceiptDigest,
|
|
666
|
+
"authorityReceiptDigest",
|
|
667
|
+
),
|
|
668
|
+
dependencyLockDigest: digest(
|
|
669
|
+
value.dependencyLockDigest,
|
|
670
|
+
"dependencyLockDigest",
|
|
671
|
+
),
|
|
672
|
+
fence: safeInteger(value.fence, "fence", { minimum: 1 }),
|
|
673
|
+
lastKnownGoodReleaseDigest: digest(
|
|
674
|
+
value.lastKnownGoodReleaseDigest,
|
|
675
|
+
"lastKnownGoodReleaseDigest",
|
|
676
|
+
),
|
|
677
|
+
revision: safeInteger(value.revision, "revision", { minimum: 1 }),
|
|
678
|
+
skillName: skillName(value.skillName),
|
|
679
|
+
tenantId: tenantId(value.tenantId, "state tenantId"),
|
|
680
|
+
transactionId: digest(value.transactionId, "transactionId"),
|
|
681
|
+
});
|
|
682
|
+
if (normalized.stateDigest !== value.stateDigest) {
|
|
683
|
+
throw failure("SKILL_RELEASE_STATE_CORRUPT", "state digest is invalid");
|
|
684
|
+
}
|
|
685
|
+
return normalized;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const PREPARE_PROJECTION_KEYS = new Set([
|
|
689
|
+
"authenticated",
|
|
690
|
+
"authorityReceiptDigest",
|
|
691
|
+
"durable",
|
|
692
|
+
"epoch",
|
|
693
|
+
"headDigest",
|
|
694
|
+
"intentDigest",
|
|
695
|
+
"ledgerId",
|
|
696
|
+
"receiptDigest",
|
|
697
|
+
"schema",
|
|
698
|
+
"sequence",
|
|
699
|
+
"status",
|
|
700
|
+
"transactionId",
|
|
701
|
+
]);
|
|
702
|
+
const COMMITTED_PROJECTION_KEYS = new Set([
|
|
703
|
+
...PREPARE_PROJECTION_KEYS,
|
|
704
|
+
"current",
|
|
705
|
+
"pointerDigest",
|
|
706
|
+
"prepareReceiptDigest",
|
|
707
|
+
"revision",
|
|
708
|
+
"skillName",
|
|
709
|
+
"stateDigest",
|
|
710
|
+
]);
|
|
711
|
+
const ABSENT_PROJECTION_KEYS = new Set([
|
|
712
|
+
"authenticated",
|
|
713
|
+
"durable",
|
|
714
|
+
"schema",
|
|
715
|
+
"status",
|
|
716
|
+
"transactionId",
|
|
717
|
+
]);
|
|
718
|
+
|
|
719
|
+
/*
|
|
720
|
+
* `transactionLedger` is a trusted domain adapter, not a raw JSON ledger.
|
|
721
|
+
* It must authenticate the underlying ledger identity/head/anchor and the
|
|
722
|
+
* authority audit head before returning these projections. `current` must be
|
|
723
|
+
* derived from the verified per-Skill finalize lineage, never copied from an
|
|
724
|
+
* event supplied by the caller.
|
|
725
|
+
*/
|
|
726
|
+
function verifyLedgerProjection(value, expected) {
|
|
727
|
+
const keys =
|
|
728
|
+
value?.status === "absent"
|
|
729
|
+
? ABSENT_PROJECTION_KEYS
|
|
730
|
+
: value?.status === "prepared"
|
|
731
|
+
? PREPARE_PROJECTION_KEYS
|
|
732
|
+
: COMMITTED_PROJECTION_KEYS;
|
|
733
|
+
assertExactKeys(
|
|
734
|
+
value,
|
|
735
|
+
keys,
|
|
736
|
+
"transaction ledger projection",
|
|
737
|
+
"SKILL_RELEASE_LEDGER_INVALID",
|
|
738
|
+
);
|
|
739
|
+
if (
|
|
740
|
+
value.schema !== SKILL_RELEASE_LEDGER_PROJECTION_SCHEMA ||
|
|
741
|
+
value.authenticated !== true ||
|
|
742
|
+
value.durable !== true ||
|
|
743
|
+
value.transactionId !== expected.transactionId ||
|
|
744
|
+
!["absent", "prepared", "committed"].includes(value.status)
|
|
745
|
+
) {
|
|
746
|
+
throw failure(
|
|
747
|
+
"SKILL_RELEASE_LEDGER_INVALID",
|
|
748
|
+
"transaction ledger projection is not authenticated and bound",
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
if (value.status === "absent") return deepFreeze({ ...value });
|
|
752
|
+
if (
|
|
753
|
+
(expected.intentDigest !== undefined &&
|
|
754
|
+
value.intentDigest !== expected.intentDigest) ||
|
|
755
|
+
(expected.authorityReceiptDigest !== undefined &&
|
|
756
|
+
value.authorityReceiptDigest !== expected.authorityReceiptDigest) ||
|
|
757
|
+
typeof value.ledgerId !== "string" ||
|
|
758
|
+
value.ledgerId.length < 1 ||
|
|
759
|
+
typeof value.epoch !== "string" ||
|
|
760
|
+
!LEDGER_EPOCH_PATTERN.test(value.epoch) ||
|
|
761
|
+
!Number.isSafeInteger(value.sequence) ||
|
|
762
|
+
value.sequence < 1
|
|
763
|
+
) {
|
|
764
|
+
throw failure(
|
|
765
|
+
"SKILL_RELEASE_LEDGER_INVALID",
|
|
766
|
+
"transaction ledger projection does not match the transition intent",
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
for (const field of ["headDigest", "receiptDigest"])
|
|
770
|
+
digest(value[field], field);
|
|
771
|
+
if (value.status === "committed") {
|
|
772
|
+
if (
|
|
773
|
+
value.stateDigest !== expected.stateDigest ||
|
|
774
|
+
value.pointerDigest !== expected.pointerDigest ||
|
|
775
|
+
(expected.skillName !== undefined &&
|
|
776
|
+
value.skillName !== expected.skillName) ||
|
|
777
|
+
(expected.revision !== undefined &&
|
|
778
|
+
value.revision !== expected.revision) ||
|
|
779
|
+
typeof value.current !== "boolean" ||
|
|
780
|
+
(expected.prepareReceiptDigest !== undefined &&
|
|
781
|
+
value.prepareReceiptDigest !== expected.prepareReceiptDigest)
|
|
782
|
+
) {
|
|
783
|
+
throw failure(
|
|
784
|
+
"SKILL_RELEASE_LEDGER_INVALID",
|
|
785
|
+
"committed projection does not bind the active pointer",
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
digest(value.prepareReceiptDigest, "prepareReceiptDigest");
|
|
789
|
+
digest(value.stateDigest, "stateDigest");
|
|
790
|
+
digest(value.pointerDigest, "pointerDigest");
|
|
791
|
+
skillName(value.skillName);
|
|
792
|
+
safeInteger(value.revision, "projection.revision", { minimum: 1 });
|
|
793
|
+
}
|
|
794
|
+
return deepFreeze({ ...value });
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function samePath(left, right) {
|
|
798
|
+
const a = path.resolve(left);
|
|
799
|
+
const b = path.resolve(right);
|
|
800
|
+
return process.platform === "win32"
|
|
801
|
+
? a.toLowerCase() === b.toLowerCase()
|
|
802
|
+
: a === b;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function isContained(root, candidate) {
|
|
806
|
+
const relation = path.relative(root, candidate);
|
|
807
|
+
return (
|
|
808
|
+
relation === "" ||
|
|
809
|
+
(relation !== ".." &&
|
|
810
|
+
!relation.startsWith(`..${path.sep}`) &&
|
|
811
|
+
!path.isAbsolute(relation))
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function identity(stat) {
|
|
816
|
+
return `${String(stat.dev)}:${String(stat.ino)}`;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function realpath(fsImpl, value) {
|
|
820
|
+
const implementation = fsImpl.realpathSync?.native || fsImpl.realpathSync;
|
|
821
|
+
if (typeof implementation !== "function") {
|
|
822
|
+
throw failure("SKILL_RELEASE_STORE_UNSAFE", "realpath is unavailable");
|
|
823
|
+
}
|
|
824
|
+
return path.resolve(implementation(value));
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function fsyncDirectory(fsImpl, directory) {
|
|
828
|
+
let descriptor = null;
|
|
829
|
+
try {
|
|
830
|
+
descriptor = fsImpl.openSync(directory, "r");
|
|
831
|
+
fsImpl.fsyncSync(descriptor);
|
|
832
|
+
} catch (error) {
|
|
833
|
+
if (!(
|
|
834
|
+
process.platform === "win32" &&
|
|
835
|
+
["EACCES", "EINVAL", "EISDIR", "EPERM"].includes(error?.code)
|
|
836
|
+
)) {
|
|
837
|
+
throw error;
|
|
838
|
+
}
|
|
839
|
+
} finally {
|
|
840
|
+
if (descriptor !== null) fsImpl.closeSync(descriptor);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const TENANT_MARKER_KEYS = new Set([
|
|
845
|
+
"component",
|
|
846
|
+
"markerDigest",
|
|
847
|
+
"schema",
|
|
848
|
+
"tenantId",
|
|
849
|
+
"tenantKey",
|
|
850
|
+
]);
|
|
851
|
+
const REGISTRY_OPTION_KEYS = new Set([
|
|
852
|
+
"crashHook",
|
|
853
|
+
"fsImpl",
|
|
854
|
+
"leaseTtlMs",
|
|
855
|
+
"now",
|
|
856
|
+
"randomToken",
|
|
857
|
+
"rootDir",
|
|
858
|
+
"secure",
|
|
859
|
+
"tenantId",
|
|
860
|
+
"transactionLedger",
|
|
861
|
+
]);
|
|
862
|
+
|
|
863
|
+
function buildTenantMarker(ownerTenantId, tenantKey) {
|
|
864
|
+
const core = {
|
|
865
|
+
schema: SKILL_RELEASE_TENANT_MARKER_SCHEMA,
|
|
866
|
+
component: TENANT_MARKER_COMPONENT,
|
|
867
|
+
tenantId: ownerTenantId,
|
|
868
|
+
tenantKey,
|
|
869
|
+
};
|
|
870
|
+
return deepFreeze({
|
|
871
|
+
...core,
|
|
872
|
+
markerDigest: domainDigest(TENANT_MARKER_DIGEST_DOMAIN, core),
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function serializeTenantMarker(marker) {
|
|
877
|
+
const bytes = Buffer.from(`${canonicalJson(marker)}\n`, "utf8");
|
|
878
|
+
if (bytes.length < 1 || bytes.length > MAX_TENANT_MARKER_BYTES) {
|
|
879
|
+
throw failure(
|
|
880
|
+
"SKILL_RELEASE_STORE_UNSAFE",
|
|
881
|
+
"release tenant marker exceeds its size limit",
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
return bytes;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function verifyTenantMarker(value, expectedTenantId, expectedTenantKey) {
|
|
888
|
+
assertExactKeys(
|
|
889
|
+
value,
|
|
890
|
+
TENANT_MARKER_KEYS,
|
|
891
|
+
"release tenant marker",
|
|
892
|
+
"SKILL_RELEASE_STORE_UNSAFE",
|
|
893
|
+
);
|
|
894
|
+
const normalizedTenantId = tenantId(value.tenantId, "marker tenantId");
|
|
895
|
+
if (
|
|
896
|
+
value.schema !== SKILL_RELEASE_TENANT_MARKER_SCHEMA ||
|
|
897
|
+
value.component !== TENANT_MARKER_COMPONENT ||
|
|
898
|
+
typeof value.tenantKey !== "string" ||
|
|
899
|
+
!TENANT_KEY_PATTERN.test(value.tenantKey)
|
|
900
|
+
) {
|
|
901
|
+
throw failure(
|
|
902
|
+
"SKILL_RELEASE_STORE_UNSAFE",
|
|
903
|
+
"release tenant marker contract is invalid",
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
const normalized = buildTenantMarker(normalizedTenantId, value.tenantKey);
|
|
907
|
+
if (
|
|
908
|
+
value.markerDigest !== normalized.markerDigest ||
|
|
909
|
+
normalizedTenantId !== expectedTenantId ||
|
|
910
|
+
value.tenantKey !== expectedTenantKey ||
|
|
911
|
+
value.tenantKey !== deriveSkillReleaseTenantKey(normalizedTenantId)
|
|
912
|
+
) {
|
|
913
|
+
throw failure(
|
|
914
|
+
"SKILL_RELEASE_STORE_UNSAFE",
|
|
915
|
+
"release tenant marker belongs to another tenant or root",
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
return normalized;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function lstatOrNull(fsImpl, target) {
|
|
922
|
+
try {
|
|
923
|
+
return fsImpl.lstatSync(target);
|
|
924
|
+
} catch (error) {
|
|
925
|
+
if (error?.code === "ENOENT") return null;
|
|
926
|
+
throw error;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function readBoundedSingleLinkFile(fsImpl, filePath, maximum, code, label) {
|
|
931
|
+
let descriptor = null;
|
|
932
|
+
try {
|
|
933
|
+
const before = fsImpl.lstatSync(filePath);
|
|
934
|
+
if (
|
|
935
|
+
!before.isFile() ||
|
|
936
|
+
before.isSymbolicLink() ||
|
|
937
|
+
Number(before.nlink) !== 1 ||
|
|
938
|
+
before.size < 1 ||
|
|
939
|
+
before.size > maximum ||
|
|
940
|
+
!samePath(realpath(fsImpl, filePath), filePath)
|
|
941
|
+
) {
|
|
942
|
+
throw failure(code, `${label} must be a bounded single-link file`);
|
|
943
|
+
}
|
|
944
|
+
descriptor = fsImpl.openSync(
|
|
945
|
+
filePath,
|
|
946
|
+
fsImpl.constants.O_RDONLY | (fsImpl.constants.O_NOFOLLOW || 0),
|
|
947
|
+
);
|
|
948
|
+
const opened = fsImpl.fstatSync(descriptor);
|
|
949
|
+
if (
|
|
950
|
+
!opened.isFile() ||
|
|
951
|
+
Number(opened.nlink) !== 1 ||
|
|
952
|
+
identity(opened) !== identity(before) ||
|
|
953
|
+
opened.size !== before.size
|
|
954
|
+
) {
|
|
955
|
+
throw failure(code, `${label} changed while opening`);
|
|
956
|
+
}
|
|
957
|
+
const bytes = fsImpl.readFileSync(descriptor);
|
|
958
|
+
const after = fsImpl.fstatSync(descriptor);
|
|
959
|
+
const afterPath = fsImpl.lstatSync(filePath);
|
|
960
|
+
if (
|
|
961
|
+
Number(after.nlink) !== 1 ||
|
|
962
|
+
identity(after) !== identity(opened) ||
|
|
963
|
+
after.size !== opened.size ||
|
|
964
|
+
bytes.length !== opened.size ||
|
|
965
|
+
!afterPath.isFile() ||
|
|
966
|
+
afterPath.isSymbolicLink() ||
|
|
967
|
+
Number(afterPath.nlink) !== 1 ||
|
|
968
|
+
identity(afterPath) !== identity(opened) ||
|
|
969
|
+
!samePath(realpath(fsImpl, filePath), filePath)
|
|
970
|
+
) {
|
|
971
|
+
throw failure(code, `${label} changed while reading`);
|
|
972
|
+
}
|
|
973
|
+
return { bytes, identity: identity(opened) };
|
|
974
|
+
} finally {
|
|
975
|
+
if (descriptor !== null) fsImpl.closeSync(descriptor);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function normalizeRegistryOptions(options) {
|
|
980
|
+
if (
|
|
981
|
+
!options ||
|
|
982
|
+
typeof options !== "object" ||
|
|
983
|
+
Array.isArray(options) ||
|
|
984
|
+
utilTypes.isProxy(options) ||
|
|
985
|
+
!isPlainObject(options)
|
|
986
|
+
) {
|
|
987
|
+
throw failure(
|
|
988
|
+
"SKILL_RELEASE_INVALID",
|
|
989
|
+
"release registry options must be an explicit object",
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
const keys = Reflect.ownKeys(options);
|
|
993
|
+
if (
|
|
994
|
+
!Object.hasOwn(options, "tenantId") ||
|
|
995
|
+
keys.some(
|
|
996
|
+
(key) => typeof key !== "string" || !REGISTRY_OPTION_KEYS.has(key),
|
|
997
|
+
)
|
|
998
|
+
) {
|
|
999
|
+
throw failure(
|
|
1000
|
+
"SKILL_RELEASE_INVALID",
|
|
1001
|
+
"release registry options must include tenantId and only supported fields",
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
const data = {};
|
|
1005
|
+
for (const key of keys) {
|
|
1006
|
+
const descriptor = Object.getOwnPropertyDescriptor(options, key);
|
|
1007
|
+
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
|
|
1008
|
+
throw failure(
|
|
1009
|
+
"SKILL_RELEASE_INVALID",
|
|
1010
|
+
`release registry options.${String(key)} must be an own data field`,
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
data[key] = descriptor.value;
|
|
1014
|
+
}
|
|
1015
|
+
return {
|
|
1016
|
+
crashHook: data.crashHook ?? null,
|
|
1017
|
+
fsImpl: data.fsImpl ?? fs,
|
|
1018
|
+
leaseTtlMs: data.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS,
|
|
1019
|
+
now: data.now ?? (() => new Date()),
|
|
1020
|
+
randomToken:
|
|
1021
|
+
data.randomToken ?? (() => crypto.randomBytes(16).toString("hex")),
|
|
1022
|
+
rootDir:
|
|
1023
|
+
data.rootDir ??
|
|
1024
|
+
path.join(getHomeDir(), "evolution", "registry", "releases"),
|
|
1025
|
+
secure: data.secure ?? true,
|
|
1026
|
+
tenantId: tenantId(data.tenantId),
|
|
1027
|
+
transactionLedger: data.transactionLedger,
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
export class SkillReleaseRegistry {
|
|
1032
|
+
#fs;
|
|
1033
|
+
|
|
1034
|
+
#directories;
|
|
1035
|
+
|
|
1036
|
+
#boundaries;
|
|
1037
|
+
|
|
1038
|
+
#rootIdentity;
|
|
1039
|
+
|
|
1040
|
+
#markerPath;
|
|
1041
|
+
|
|
1042
|
+
#markerIdentity;
|
|
1043
|
+
|
|
1044
|
+
#secure;
|
|
1045
|
+
|
|
1046
|
+
#randomToken;
|
|
1047
|
+
|
|
1048
|
+
#now;
|
|
1049
|
+
|
|
1050
|
+
#leaseTtlMs;
|
|
1051
|
+
|
|
1052
|
+
#crashHook;
|
|
1053
|
+
|
|
1054
|
+
#ledgerPrepare;
|
|
1055
|
+
|
|
1056
|
+
#ledgerFinalize;
|
|
1057
|
+
|
|
1058
|
+
#ledgerQuery;
|
|
1059
|
+
|
|
1060
|
+
#pins = new WeakMap();
|
|
1061
|
+
|
|
1062
|
+
constructor(options) {
|
|
1063
|
+
const {
|
|
1064
|
+
crashHook,
|
|
1065
|
+
fsImpl,
|
|
1066
|
+
leaseTtlMs,
|
|
1067
|
+
now,
|
|
1068
|
+
randomToken,
|
|
1069
|
+
rootDir,
|
|
1070
|
+
secure,
|
|
1071
|
+
tenantId: ownerTenantId,
|
|
1072
|
+
transactionLedger,
|
|
1073
|
+
} = normalizeRegistryOptions(options);
|
|
1074
|
+
if (
|
|
1075
|
+
!transactionLedger ||
|
|
1076
|
+
typeof transactionLedger.prepare !== "function" ||
|
|
1077
|
+
typeof transactionLedger.finalize !== "function" ||
|
|
1078
|
+
typeof transactionLedger.query !== "function"
|
|
1079
|
+
) {
|
|
1080
|
+
throw failure(
|
|
1081
|
+
"SKILL_RELEASE_LEDGER_REQUIRED",
|
|
1082
|
+
"authenticated transactionLedger prepare/finalize/query ports are required",
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
if (
|
|
1086
|
+
(secure !== true && secure !== false) ||
|
|
1087
|
+
!fsImpl ||
|
|
1088
|
+
typeof fsImpl !== "object" ||
|
|
1089
|
+
utilTypes.isProxy(fsImpl) ||
|
|
1090
|
+
typeof randomToken !== "function" ||
|
|
1091
|
+
utilTypes.isProxy(randomToken) ||
|
|
1092
|
+
typeof now !== "function" ||
|
|
1093
|
+
utilTypes.isProxy(now) ||
|
|
1094
|
+
!Number.isSafeInteger(leaseTtlMs) ||
|
|
1095
|
+
leaseTtlMs < MIN_LEASE_TTL_MS ||
|
|
1096
|
+
(crashHook !== null && typeof crashHook !== "function")
|
|
1097
|
+
) {
|
|
1098
|
+
throw failure("SKILL_RELEASE_INVALID", "registry options are invalid");
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
this.#fs = fsImpl;
|
|
1102
|
+
this.#secure = secure;
|
|
1103
|
+
this.#randomToken = randomToken;
|
|
1104
|
+
this.#now = now;
|
|
1105
|
+
this.#leaseTtlMs = leaseTtlMs;
|
|
1106
|
+
this.#crashHook = crashHook;
|
|
1107
|
+
this.#ledgerPrepare = transactionLedger.prepare.bind(transactionLedger);
|
|
1108
|
+
this.#ledgerFinalize = transactionLedger.finalize.bind(transactionLedger);
|
|
1109
|
+
this.#ledgerQuery = transactionLedger.query.bind(transactionLedger);
|
|
1110
|
+
Object.freeze(transactionLedger);
|
|
1111
|
+
|
|
1112
|
+
this.tenantId = ownerTenantId;
|
|
1113
|
+
this.tenantKey = deriveSkillReleaseTenantKey(ownerTenantId);
|
|
1114
|
+
const requestedBase = path.resolve(rootDir);
|
|
1115
|
+
try {
|
|
1116
|
+
const base = this.#initializeDirectory(requestedBase, {
|
|
1117
|
+
recursive: true,
|
|
1118
|
+
});
|
|
1119
|
+
this.baseDir = base.path;
|
|
1120
|
+
this.#assertNoLegacyBaseLayout();
|
|
1121
|
+
const tenants = this.#initializeDirectory(
|
|
1122
|
+
path.join(this.baseDir, "tenants"),
|
|
1123
|
+
{ parent: base },
|
|
1124
|
+
);
|
|
1125
|
+
const tenantRoot = this.#initializeDirectory(
|
|
1126
|
+
path.join(tenants.path, this.tenantKey),
|
|
1127
|
+
{ parent: tenants },
|
|
1128
|
+
);
|
|
1129
|
+
this.rootDir = tenantRoot.path;
|
|
1130
|
+
this.#boundaries = deepFreeze({ base, tenantRoot, tenants });
|
|
1131
|
+
this.#rootIdentity = tenantRoot.identity;
|
|
1132
|
+
this.#markerPath = path.join(this.rootDir, TENANT_MARKER_FILE);
|
|
1133
|
+
this.#initializeTenantMarker();
|
|
1134
|
+
this.#directories = {};
|
|
1135
|
+
for (const name of [
|
|
1136
|
+
"artifacts",
|
|
1137
|
+
"active",
|
|
1138
|
+
"journals",
|
|
1139
|
+
"locks",
|
|
1140
|
+
"staging",
|
|
1141
|
+
]) {
|
|
1142
|
+
const directory = path.join(this.rootDir, name);
|
|
1143
|
+
this.#directories[name] = this.#initializeDirectory(directory, {
|
|
1144
|
+
parent: tenantRoot,
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
this.#directories = deepFreeze(this.#directories);
|
|
1148
|
+
this.#assertBoundary();
|
|
1149
|
+
this.#assertNoLegacyTenantSchemas();
|
|
1150
|
+
this.#recoverAll();
|
|
1151
|
+
this.#cleanupDebris();
|
|
1152
|
+
} catch (cause) {
|
|
1153
|
+
if (cause instanceof SkillReleaseRegistryError) throw cause;
|
|
1154
|
+
throw failure(
|
|
1155
|
+
"SKILL_RELEASE_STORE_UNSAFE",
|
|
1156
|
+
"registry could not be initialized safely",
|
|
1157
|
+
{ cause },
|
|
1158
|
+
);
|
|
1159
|
+
}
|
|
1160
|
+
Object.freeze(this);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
#initializeDirectory(
|
|
1164
|
+
requestedPath,
|
|
1165
|
+
{ parent = null, recursive = false } = {},
|
|
1166
|
+
) {
|
|
1167
|
+
const before = lstatOrNull(this.#fs, requestedPath);
|
|
1168
|
+
if (before && (!before.isDirectory() || before.isSymbolicLink())) {
|
|
1169
|
+
throw failure(
|
|
1170
|
+
"SKILL_RELEASE_STORE_UNSAFE",
|
|
1171
|
+
"release registry directory path is unsafe",
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
if (!before) {
|
|
1175
|
+
this.#fs.mkdirSync(requestedPath, { recursive, mode: 0o700 });
|
|
1176
|
+
}
|
|
1177
|
+
let stat = this.#fs.lstatSync(requestedPath);
|
|
1178
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
1179
|
+
throw failure(
|
|
1180
|
+
"SKILL_RELEASE_STORE_UNSAFE",
|
|
1181
|
+
"release registry directory must be non-symlink",
|
|
1182
|
+
);
|
|
1183
|
+
}
|
|
1184
|
+
const capturedIdentity = identity(stat);
|
|
1185
|
+
const canonical = realpath(this.#fs, requestedPath);
|
|
1186
|
+
const canonicalStat = this.#fs.lstatSync(canonical);
|
|
1187
|
+
const canonicalParent = realpath(this.#fs, path.dirname(requestedPath));
|
|
1188
|
+
const expectedCanonical = path.join(
|
|
1189
|
+
canonicalParent,
|
|
1190
|
+
path.basename(requestedPath),
|
|
1191
|
+
);
|
|
1192
|
+
if (
|
|
1193
|
+
!canonicalStat.isDirectory() ||
|
|
1194
|
+
canonicalStat.isSymbolicLink() ||
|
|
1195
|
+
identity(canonicalStat) !== capturedIdentity ||
|
|
1196
|
+
!samePath(canonical, expectedCanonical) ||
|
|
1197
|
+
(parent &&
|
|
1198
|
+
(!isContained(parent.path, canonical) ||
|
|
1199
|
+
!samePath(path.dirname(canonical), parent.path)))
|
|
1200
|
+
) {
|
|
1201
|
+
throw failure(
|
|
1202
|
+
"SKILL_RELEASE_STORE_UNSAFE",
|
|
1203
|
+
"release registry directory escaped its canonical parent",
|
|
1204
|
+
);
|
|
1205
|
+
}
|
|
1206
|
+
if (this.#secure) {
|
|
1207
|
+
ensurePrivateDirectory(canonical, {
|
|
1208
|
+
applyWindowsAcl: true,
|
|
1209
|
+
failIfUnavailable: true,
|
|
1210
|
+
});
|
|
1211
|
+
stat = this.#fs.lstatSync(canonical);
|
|
1212
|
+
if (
|
|
1213
|
+
!stat.isDirectory() ||
|
|
1214
|
+
stat.isSymbolicLink() ||
|
|
1215
|
+
identity(stat) !== capturedIdentity ||
|
|
1216
|
+
!samePath(realpath(this.#fs, canonical), canonical)
|
|
1217
|
+
) {
|
|
1218
|
+
throw failure(
|
|
1219
|
+
"SKILL_RELEASE_STORE_UNSAFE",
|
|
1220
|
+
"release registry directory changed during permission hardening",
|
|
1221
|
+
);
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
return deepFreeze({ path: canonical, identity: capturedIdentity });
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
#assertNoLegacyBaseLayout() {
|
|
1228
|
+
for (const entry of this.#fs.readdirSync(this.baseDir)) {
|
|
1229
|
+
if (entry !== "tenants") {
|
|
1230
|
+
throw migrationRequired(
|
|
1231
|
+
"legacy unscoped SkillRelease storage requires explicit migration",
|
|
1232
|
+
{ path: path.join(this.baseDir, entry) },
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
#initializeTenantMarker() {
|
|
1239
|
+
const existing = lstatOrNull(this.#fs, this.#markerPath);
|
|
1240
|
+
if (existing) {
|
|
1241
|
+
const verified = this.#readAndVerifyTenantMarker();
|
|
1242
|
+
this.#markerIdentity = verified.identity;
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
if (this.#fs.readdirSync(this.rootDir).length !== 0) {
|
|
1246
|
+
throw migrationRequired(
|
|
1247
|
+
"unmarked or mixed-schema release tenant storage requires explicit migration",
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
1250
|
+
const marker = buildTenantMarker(this.tenantId, this.tenantKey);
|
|
1251
|
+
const bytes = serializeTenantMarker(marker);
|
|
1252
|
+
const temporaryPath = path.join(
|
|
1253
|
+
this.rootDir,
|
|
1254
|
+
`.tenant-${process.pid}-${this.#token("SKILL_RELEASE_STORE_UNSAFE")}.tmp`,
|
|
1255
|
+
);
|
|
1256
|
+
let descriptor = null;
|
|
1257
|
+
let temporaryExists = false;
|
|
1258
|
+
try {
|
|
1259
|
+
descriptor = this.#fs.openSync(temporaryPath, "wx", 0o600);
|
|
1260
|
+
temporaryExists = true;
|
|
1261
|
+
this.#fs.writeFileSync(descriptor, bytes);
|
|
1262
|
+
this.#fs.fsyncSync(descriptor);
|
|
1263
|
+
const written = this.#fs.fstatSync(descriptor);
|
|
1264
|
+
if (
|
|
1265
|
+
!written.isFile() ||
|
|
1266
|
+
Number(written.nlink) !== 1 ||
|
|
1267
|
+
written.size !== bytes.length
|
|
1268
|
+
) {
|
|
1269
|
+
throw failure(
|
|
1270
|
+
"SKILL_RELEASE_STORE_UNSAFE",
|
|
1271
|
+
"release tenant marker temporary file is unsafe",
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1274
|
+
const writtenIdentity = identity(written);
|
|
1275
|
+
this.#fs.closeSync(descriptor);
|
|
1276
|
+
descriptor = null;
|
|
1277
|
+
if (this.#secure) {
|
|
1278
|
+
ensurePrivateFile(temporaryPath, {
|
|
1279
|
+
applyWindowsAcl: true,
|
|
1280
|
+
failIfUnavailable: true,
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
const staged = this.#fs.lstatSync(temporaryPath);
|
|
1284
|
+
if (
|
|
1285
|
+
!staged.isFile() ||
|
|
1286
|
+
staged.isSymbolicLink() ||
|
|
1287
|
+
Number(staged.nlink) !== 1 ||
|
|
1288
|
+
staged.size !== bytes.length ||
|
|
1289
|
+
identity(staged) !== writtenIdentity ||
|
|
1290
|
+
!samePath(realpath(this.#fs, temporaryPath), temporaryPath)
|
|
1291
|
+
) {
|
|
1292
|
+
throw failure(
|
|
1293
|
+
"SKILL_RELEASE_STORE_UNSAFE",
|
|
1294
|
+
"release tenant marker changed before publication",
|
|
1295
|
+
);
|
|
1296
|
+
}
|
|
1297
|
+
try {
|
|
1298
|
+
this.#fs.linkSync(temporaryPath, this.#markerPath);
|
|
1299
|
+
} catch (error) {
|
|
1300
|
+
if (error?.code !== "EEXIST") throw error;
|
|
1301
|
+
this.#fs.unlinkSync(temporaryPath);
|
|
1302
|
+
temporaryExists = false;
|
|
1303
|
+
const verified = this.#readAndVerifyTenantMarker();
|
|
1304
|
+
this.#markerIdentity = verified.identity;
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
const linked = this.#fs.lstatSync(this.#markerPath);
|
|
1308
|
+
if (
|
|
1309
|
+
!linked.isFile() ||
|
|
1310
|
+
linked.isSymbolicLink() ||
|
|
1311
|
+
Number(linked.nlink) !== 2 ||
|
|
1312
|
+
identity(linked) !== writtenIdentity
|
|
1313
|
+
) {
|
|
1314
|
+
throw failure(
|
|
1315
|
+
"SKILL_RELEASE_STORE_UNSAFE",
|
|
1316
|
+
"release tenant marker publication was unsafe",
|
|
1317
|
+
);
|
|
1318
|
+
}
|
|
1319
|
+
this.#fs.unlinkSync(temporaryPath);
|
|
1320
|
+
temporaryExists = false;
|
|
1321
|
+
fsyncDirectory(this.#fs, this.rootDir);
|
|
1322
|
+
const verified = this.#readAndVerifyTenantMarker();
|
|
1323
|
+
this.#markerIdentity = verified.identity;
|
|
1324
|
+
} finally {
|
|
1325
|
+
if (descriptor !== null) {
|
|
1326
|
+
try {
|
|
1327
|
+
this.#fs.closeSync(descriptor);
|
|
1328
|
+
} catch {
|
|
1329
|
+
// The marker was never published through the authoritative path.
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
if (temporaryExists) {
|
|
1333
|
+
try {
|
|
1334
|
+
this.#fs.unlinkSync(temporaryPath);
|
|
1335
|
+
} catch {
|
|
1336
|
+
// The next boundary verification will fail closed on linked debris.
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
#readAndVerifyTenantMarker() {
|
|
1343
|
+
try {
|
|
1344
|
+
const stored = readBoundedSingleLinkFile(
|
|
1345
|
+
this.#fs,
|
|
1346
|
+
this.#markerPath,
|
|
1347
|
+
MAX_TENANT_MARKER_BYTES,
|
|
1348
|
+
"SKILL_RELEASE_STORE_UNSAFE",
|
|
1349
|
+
"release tenant marker",
|
|
1350
|
+
);
|
|
1351
|
+
const marker = verifyTenantMarker(
|
|
1352
|
+
JSON.parse(
|
|
1353
|
+
new TextDecoder("utf-8", { fatal: true }).decode(stored.bytes),
|
|
1354
|
+
),
|
|
1355
|
+
this.tenantId,
|
|
1356
|
+
this.tenantKey,
|
|
1357
|
+
);
|
|
1358
|
+
if (!serializeTenantMarker(marker).equals(stored.bytes)) {
|
|
1359
|
+
throw failure(
|
|
1360
|
+
"SKILL_RELEASE_STORE_UNSAFE",
|
|
1361
|
+
"release tenant marker is not canonical JSON",
|
|
1362
|
+
);
|
|
1363
|
+
}
|
|
1364
|
+
return { identity: stored.identity, marker };
|
|
1365
|
+
} catch (cause) {
|
|
1366
|
+
if (cause instanceof SkillReleaseRegistryError) throw cause;
|
|
1367
|
+
throw failure(
|
|
1368
|
+
"SKILL_RELEASE_STORE_UNSAFE",
|
|
1369
|
+
"release tenant marker could not be verified",
|
|
1370
|
+
{ cause },
|
|
1371
|
+
);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
#clock() {
|
|
1376
|
+
const value = this.#now();
|
|
1377
|
+
const date =
|
|
1378
|
+
value instanceof Date ? new Date(value.getTime()) : new Date(value);
|
|
1379
|
+
if (!Number.isFinite(date.getTime())) {
|
|
1380
|
+
throw failure("SKILL_RELEASE_CLOCK_INVALID", "registry clock is invalid");
|
|
1381
|
+
}
|
|
1382
|
+
return date;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
#token(code = "SKILL_RELEASE_WRITE_FAILED") {
|
|
1386
|
+
const value = String(this.#randomToken());
|
|
1387
|
+
if (!TOKEN_PATTERN.test(value)) {
|
|
1388
|
+
throw failure(code, "random token is invalid");
|
|
1389
|
+
}
|
|
1390
|
+
return value;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
#assertBoundary() {
|
|
1394
|
+
for (const entry of [
|
|
1395
|
+
...Object.values(this.#boundaries),
|
|
1396
|
+
...Object.values(this.#directories),
|
|
1397
|
+
]) {
|
|
1398
|
+
const stat = this.#fs.lstatSync(entry.path);
|
|
1399
|
+
if (
|
|
1400
|
+
!stat.isDirectory() ||
|
|
1401
|
+
stat.isSymbolicLink() ||
|
|
1402
|
+
identity(stat) !== entry.identity ||
|
|
1403
|
+
!samePath(realpath(this.#fs, entry.path), entry.path) ||
|
|
1404
|
+
(this.#directories &&
|
|
1405
|
+
Object.values(this.#directories).includes(entry) &&
|
|
1406
|
+
(!isContained(this.rootDir, entry.path) ||
|
|
1407
|
+
!samePath(path.dirname(entry.path), this.rootDir)))
|
|
1408
|
+
) {
|
|
1409
|
+
throw failure(
|
|
1410
|
+
"SKILL_RELEASE_STORE_UNSAFE",
|
|
1411
|
+
"registry directory changed",
|
|
1412
|
+
);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
if (
|
|
1416
|
+
!samePath(this.#boundaries.base.path, this.baseDir) ||
|
|
1417
|
+
!samePath(this.#boundaries.tenantRoot.path, this.rootDir) ||
|
|
1418
|
+
identity(this.#fs.lstatSync(this.rootDir)) !== this.#rootIdentity ||
|
|
1419
|
+
!samePath(path.dirname(this.#boundaries.tenants.path), this.baseDir) ||
|
|
1420
|
+
!samePath(path.dirname(this.rootDir), this.#boundaries.tenants.path) ||
|
|
1421
|
+
!isContained(this.baseDir, this.rootDir)
|
|
1422
|
+
) {
|
|
1423
|
+
throw failure(
|
|
1424
|
+
"SKILL_RELEASE_STORE_UNSAFE",
|
|
1425
|
+
"release registry directory topology changed",
|
|
1426
|
+
);
|
|
1427
|
+
}
|
|
1428
|
+
const expectedRootEntries = new Set([
|
|
1429
|
+
TENANT_MARKER_FILE,
|
|
1430
|
+
...Object.keys(this.#directories),
|
|
1431
|
+
]);
|
|
1432
|
+
if (
|
|
1433
|
+
this.#fs
|
|
1434
|
+
.readdirSync(this.rootDir)
|
|
1435
|
+
.some((name) => !expectedRootEntries.has(name))
|
|
1436
|
+
) {
|
|
1437
|
+
throw failure(
|
|
1438
|
+
"SKILL_RELEASE_STORE_UNSAFE",
|
|
1439
|
+
"release tenant root contains an unexpected entry",
|
|
1440
|
+
);
|
|
1441
|
+
}
|
|
1442
|
+
const verifiedMarker = this.#readAndVerifyTenantMarker();
|
|
1443
|
+
if (
|
|
1444
|
+
this.#markerIdentity !== undefined &&
|
|
1445
|
+
verifiedMarker.identity !== this.#markerIdentity
|
|
1446
|
+
) {
|
|
1447
|
+
throw failure(
|
|
1448
|
+
"SKILL_RELEASE_STORE_UNSAFE",
|
|
1449
|
+
"release tenant marker identity changed",
|
|
1450
|
+
);
|
|
1451
|
+
}
|
|
1452
|
+
for (const entry of [
|
|
1453
|
+
...Object.values(this.#boundaries),
|
|
1454
|
+
...Object.values(this.#directories),
|
|
1455
|
+
]) {
|
|
1456
|
+
const stat = this.#fs.lstatSync(entry.path);
|
|
1457
|
+
if (
|
|
1458
|
+
!stat.isDirectory() ||
|
|
1459
|
+
stat.isSymbolicLink() ||
|
|
1460
|
+
identity(stat) !== entry.identity ||
|
|
1461
|
+
!samePath(realpath(this.#fs, entry.path), entry.path)
|
|
1462
|
+
) {
|
|
1463
|
+
throw failure(
|
|
1464
|
+
"SKILL_RELEASE_STORE_UNSAFE",
|
|
1465
|
+
"release registry boundary changed during marker verification",
|
|
1466
|
+
);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
#assertNoLegacyTenantSchemas() {
|
|
1472
|
+
const expectedRootEntries = new Set([
|
|
1473
|
+
TENANT_MARKER_FILE,
|
|
1474
|
+
...Object.keys(this.#directories),
|
|
1475
|
+
]);
|
|
1476
|
+
for (const name of this.#fs.readdirSync(this.rootDir)) {
|
|
1477
|
+
if (!expectedRootEntries.has(name)) {
|
|
1478
|
+
throw migrationRequired(
|
|
1479
|
+
"mixed release tenant layout requires explicit migration",
|
|
1480
|
+
{ path: path.join(this.rootDir, name) },
|
|
1481
|
+
);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
const areas = [
|
|
1485
|
+
["artifacts", LEGACY_RELEASE_SCHEMA],
|
|
1486
|
+
["active", LEGACY_STATE_SCHEMA],
|
|
1487
|
+
["journals", LEGACY_JOURNAL_SCHEMA],
|
|
1488
|
+
];
|
|
1489
|
+
for (const [area, legacySchema] of areas) {
|
|
1490
|
+
for (const name of this.#fs.readdirSync(this.#directories[area].path)) {
|
|
1491
|
+
if (!name.endsWith(".json")) continue;
|
|
1492
|
+
const value = this.#readJson(
|
|
1493
|
+
this.#path(area, name),
|
|
1494
|
+
"SKILL_RELEASE_STORE_UNSAFE",
|
|
1495
|
+
);
|
|
1496
|
+
if (value?.schema === legacySchema) {
|
|
1497
|
+
throw migrationRequired(
|
|
1498
|
+
"mixed legacy SkillRelease tenant storage requires explicit migration",
|
|
1499
|
+
{ path: this.#path(area, name), schema: legacySchema },
|
|
1500
|
+
);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
#path(area, fileName) {
|
|
1507
|
+
const directory = this.#directories[area]?.path;
|
|
1508
|
+
const target = path.resolve(directory, fileName);
|
|
1509
|
+
if (!directory || !isContained(directory, target) || target === directory) {
|
|
1510
|
+
throw failure(
|
|
1511
|
+
"SKILL_RELEASE_STORE_UNSAFE",
|
|
1512
|
+
"registry path escaped its area",
|
|
1513
|
+
);
|
|
1514
|
+
}
|
|
1515
|
+
return target;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
#releasePath(releaseDigest) {
|
|
1519
|
+
return this.#path(
|
|
1520
|
+
"artifacts",
|
|
1521
|
+
`${digest(releaseDigest, "releaseDigest").slice(7)}.json`,
|
|
1522
|
+
);
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
#statePath(name) {
|
|
1526
|
+
return this.#path("active", `${skillName(name)}.json`);
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
#journalPath(name) {
|
|
1530
|
+
return this.#path("journals", `${skillName(name)}.json`);
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
#leasePath(name) {
|
|
1534
|
+
return this.#path("locks", `${skillName(name)}.lock`);
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
#recoveryLockPath() {
|
|
1538
|
+
return this.#path("locks", ".recovery.lock");
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
#exists(filePath) {
|
|
1542
|
+
try {
|
|
1543
|
+
this.#fs.lstatSync(filePath);
|
|
1544
|
+
return true;
|
|
1545
|
+
} catch (error) {
|
|
1546
|
+
if (error?.code === "ENOENT") return false;
|
|
1547
|
+
throw error;
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
#readBytes(filePath, code) {
|
|
1552
|
+
this.#assertBoundary();
|
|
1553
|
+
try {
|
|
1554
|
+
return readBoundedSingleLinkFile(
|
|
1555
|
+
this.#fs,
|
|
1556
|
+
filePath,
|
|
1557
|
+
MAX_FILE_BYTES,
|
|
1558
|
+
code,
|
|
1559
|
+
"release registry artifact",
|
|
1560
|
+
).bytes;
|
|
1561
|
+
} finally {
|
|
1562
|
+
this.#assertBoundary();
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
#parseCanonical(bytes, code) {
|
|
1567
|
+
let parsed;
|
|
1568
|
+
try {
|
|
1569
|
+
parsed = JSON.parse(
|
|
1570
|
+
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
|
|
1571
|
+
);
|
|
1572
|
+
} catch (cause) {
|
|
1573
|
+
throw failure(code, "artifact is not UTF-8 JSON", { cause });
|
|
1574
|
+
}
|
|
1575
|
+
assertBoundedCanonicalPreflight(parsed, code);
|
|
1576
|
+
if (!serialize(parsed).equals(bytes)) {
|
|
1577
|
+
throw failure(code, "artifact is not canonical JSON");
|
|
1578
|
+
}
|
|
1579
|
+
return parsed;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
#readJson(filePath, code) {
|
|
1583
|
+
return this.#parseCanonical(this.#readBytes(filePath, code), code);
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
#writeTemporary(fileName, value) {
|
|
1587
|
+
if (!TEMP_PATTERN.test(fileName)) {
|
|
1588
|
+
throw failure("SKILL_RELEASE_STORE_UNSAFE", "temporary name is invalid");
|
|
1589
|
+
}
|
|
1590
|
+
const filePath = this.#path("staging", fileName);
|
|
1591
|
+
const bytes = serialize(value);
|
|
1592
|
+
let descriptor = null;
|
|
1593
|
+
let writtenIdentity = null;
|
|
1594
|
+
try {
|
|
1595
|
+
this.#assertBoundary();
|
|
1596
|
+
descriptor = this.#fs.openSync(filePath, "wx", 0o600);
|
|
1597
|
+
this.#fs.writeFileSync(descriptor, bytes);
|
|
1598
|
+
this.#fs.fsyncSync(descriptor);
|
|
1599
|
+
const written = this.#fs.fstatSync(descriptor);
|
|
1600
|
+
if (
|
|
1601
|
+
!written.isFile() ||
|
|
1602
|
+
Number(written.nlink) !== 1 ||
|
|
1603
|
+
written.size !== bytes.length
|
|
1604
|
+
) {
|
|
1605
|
+
throw failure(
|
|
1606
|
+
"SKILL_RELEASE_WRITE_FAILED",
|
|
1607
|
+
"temporary write was incomplete",
|
|
1608
|
+
);
|
|
1609
|
+
}
|
|
1610
|
+
writtenIdentity = identity(written);
|
|
1611
|
+
} finally {
|
|
1612
|
+
if (descriptor !== null) this.#fs.closeSync(descriptor);
|
|
1613
|
+
}
|
|
1614
|
+
if (this.#secure) {
|
|
1615
|
+
ensurePrivateFile(filePath, {
|
|
1616
|
+
applyWindowsAcl: true,
|
|
1617
|
+
failIfUnavailable: true,
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
const staged = this.#fs.lstatSync(filePath);
|
|
1621
|
+
if (
|
|
1622
|
+
!staged.isFile() ||
|
|
1623
|
+
staged.isSymbolicLink() ||
|
|
1624
|
+
Number(staged.nlink) !== 1 ||
|
|
1625
|
+
identity(staged) !== writtenIdentity ||
|
|
1626
|
+
staged.size !== bytes.length ||
|
|
1627
|
+
!samePath(realpath(this.#fs, filePath), filePath)
|
|
1628
|
+
) {
|
|
1629
|
+
throw failure(
|
|
1630
|
+
"SKILL_RELEASE_WRITE_FAILED",
|
|
1631
|
+
"temporary artifact changed before publication",
|
|
1632
|
+
);
|
|
1633
|
+
}
|
|
1634
|
+
fsyncDirectory(this.#fs, this.#directories.staging.path);
|
|
1635
|
+
this.#assertBoundary();
|
|
1636
|
+
return filePath;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
#atomicWrite(area, destination, value) {
|
|
1640
|
+
const expectedBytes = serialize(value);
|
|
1641
|
+
const name = `.write-${process.pid}-${this.#token()}.tmp`;
|
|
1642
|
+
const temporary = this.#writeTemporary(name, value);
|
|
1643
|
+
const temporaryStat = this.#fs.lstatSync(temporary);
|
|
1644
|
+
this.#fs.renameSync(temporary, destination);
|
|
1645
|
+
const destinationDirectory = path.dirname(destination);
|
|
1646
|
+
const destinationStat = this.#fs.lstatSync(destinationDirectory);
|
|
1647
|
+
if (!destinationStat.isDirectory() || destinationStat.isSymbolicLink()) {
|
|
1648
|
+
throw failure(
|
|
1649
|
+
"SKILL_RELEASE_STORE_UNSAFE",
|
|
1650
|
+
"atomic write destination directory is unsafe",
|
|
1651
|
+
);
|
|
1652
|
+
}
|
|
1653
|
+
const published = this.#fs.lstatSync(destination);
|
|
1654
|
+
if (
|
|
1655
|
+
!published.isFile() ||
|
|
1656
|
+
published.isSymbolicLink() ||
|
|
1657
|
+
Number(published.nlink) !== 1 ||
|
|
1658
|
+
identity(published) !== identity(temporaryStat) ||
|
|
1659
|
+
!samePath(realpath(this.#fs, destination), destination) ||
|
|
1660
|
+
!isContained(this.#directories[area].path, destination)
|
|
1661
|
+
) {
|
|
1662
|
+
throw failure(
|
|
1663
|
+
"SKILL_RELEASE_STORE_UNSAFE",
|
|
1664
|
+
"atomic write destination changed during publication",
|
|
1665
|
+
);
|
|
1666
|
+
}
|
|
1667
|
+
if (
|
|
1668
|
+
!this.#readBytes(destination, "SKILL_RELEASE_WRITE_FAILED").equals(
|
|
1669
|
+
expectedBytes,
|
|
1670
|
+
)
|
|
1671
|
+
) {
|
|
1672
|
+
throw failure(
|
|
1673
|
+
"SKILL_RELEASE_WRITE_FAILED",
|
|
1674
|
+
"atomic write publication did not preserve exact bytes",
|
|
1675
|
+
);
|
|
1676
|
+
}
|
|
1677
|
+
fsyncDirectory(this.#fs, destinationDirectory);
|
|
1678
|
+
if (!samePath(destinationDirectory, this.#directories[area].path)) {
|
|
1679
|
+
fsyncDirectory(this.#fs, this.#directories[area].path);
|
|
1680
|
+
}
|
|
1681
|
+
this.#assertBoundary();
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
#unlink(filePath, directory) {
|
|
1685
|
+
try {
|
|
1686
|
+
this.#fs.unlinkSync(filePath);
|
|
1687
|
+
fsyncDirectory(this.#fs, directory);
|
|
1688
|
+
} catch (error) {
|
|
1689
|
+
if (error?.code !== "ENOENT") throw error;
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
#publishRelease(release) {
|
|
1694
|
+
const destination = this.#releasePath(release.releaseDigest);
|
|
1695
|
+
const name = `.release-${process.pid}-${this.#token()}.tmp`;
|
|
1696
|
+
const temporary = this.#writeTemporary(name, release);
|
|
1697
|
+
const bytes = serialize(release);
|
|
1698
|
+
const temporaryStat = this.#fs.lstatSync(temporary);
|
|
1699
|
+
const temporaryIdentity = identity(temporaryStat);
|
|
1700
|
+
try {
|
|
1701
|
+
try {
|
|
1702
|
+
this.#fs.linkSync(temporary, destination);
|
|
1703
|
+
} catch (error) {
|
|
1704
|
+
if (error?.code !== "EEXIST") throw error;
|
|
1705
|
+
const existing = this.readRelease(release.releaseDigest);
|
|
1706
|
+
if (!serialize(existing).equals(bytes)) {
|
|
1707
|
+
throw failure("SKILL_RELEASE_CONFLICT", "release digest collision");
|
|
1708
|
+
}
|
|
1709
|
+
return false;
|
|
1710
|
+
}
|
|
1711
|
+
const linked = this.#fs.lstatSync(destination);
|
|
1712
|
+
if (
|
|
1713
|
+
!linked.isFile() ||
|
|
1714
|
+
linked.isSymbolicLink() ||
|
|
1715
|
+
Number(linked.nlink) !== 2 ||
|
|
1716
|
+
identity(linked) !== temporaryIdentity
|
|
1717
|
+
) {
|
|
1718
|
+
throw failure(
|
|
1719
|
+
"SKILL_RELEASE_WRITE_FAILED",
|
|
1720
|
+
"release hardlink publication was unsafe",
|
|
1721
|
+
);
|
|
1722
|
+
}
|
|
1723
|
+
fsyncDirectory(this.#fs, this.#directories.artifacts.path);
|
|
1724
|
+
this.#unlink(temporary, this.#directories.staging.path);
|
|
1725
|
+
const published = this.#fs.lstatSync(destination);
|
|
1726
|
+
if (
|
|
1727
|
+
!published.isFile() ||
|
|
1728
|
+
published.isSymbolicLink() ||
|
|
1729
|
+
Number(published.nlink) !== 1 ||
|
|
1730
|
+
identity(published) !== temporaryIdentity ||
|
|
1731
|
+
!samePath(realpath(this.#fs, destination), destination)
|
|
1732
|
+
) {
|
|
1733
|
+
throw failure(
|
|
1734
|
+
"SKILL_RELEASE_WRITE_FAILED",
|
|
1735
|
+
"published release did not become a single-link artifact",
|
|
1736
|
+
);
|
|
1737
|
+
}
|
|
1738
|
+
if (
|
|
1739
|
+
!this.#readBytes(destination, "SKILL_RELEASE_WRITE_FAILED").equals(
|
|
1740
|
+
bytes,
|
|
1741
|
+
)
|
|
1742
|
+
) {
|
|
1743
|
+
throw failure(
|
|
1744
|
+
"SKILL_RELEASE_WRITE_FAILED",
|
|
1745
|
+
"published release did not preserve exact bytes",
|
|
1746
|
+
);
|
|
1747
|
+
}
|
|
1748
|
+
this.#assertBoundary();
|
|
1749
|
+
return true;
|
|
1750
|
+
} finally {
|
|
1751
|
+
this.#unlink(temporary, this.#directories.staging.path);
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
#createRelease(input) {
|
|
1756
|
+
const release = buildSkillRelease(input);
|
|
1757
|
+
const created = this.#publishRelease(release);
|
|
1758
|
+
return deepFreeze({ release, created });
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
readRelease(releaseDigest) {
|
|
1762
|
+
const expected = digest(releaseDigest, "releaseDigest");
|
|
1763
|
+
let value;
|
|
1764
|
+
try {
|
|
1765
|
+
value = this.#readJson(
|
|
1766
|
+
this.#releasePath(expected),
|
|
1767
|
+
"SKILL_RELEASE_CORRUPT",
|
|
1768
|
+
);
|
|
1769
|
+
} catch (cause) {
|
|
1770
|
+
if (cause instanceof SkillReleaseRegistryError) throw cause;
|
|
1771
|
+
if (cause?.code === "ENOENT") {
|
|
1772
|
+
throw failure("SKILL_RELEASE_NOT_FOUND", "release was not found");
|
|
1773
|
+
}
|
|
1774
|
+
throw failure("SKILL_RELEASE_CORRUPT", "release could not be read", {
|
|
1775
|
+
cause,
|
|
1776
|
+
});
|
|
1777
|
+
}
|
|
1778
|
+
let release;
|
|
1779
|
+
try {
|
|
1780
|
+
release = verifySkillRelease(value);
|
|
1781
|
+
} catch (cause) {
|
|
1782
|
+
if (cause?.code === SKILL_RELEASE_MIGRATION_REQUIRED_CODE) throw cause;
|
|
1783
|
+
throw failure("SKILL_RELEASE_CORRUPT", "release verification failed", {
|
|
1784
|
+
cause,
|
|
1785
|
+
});
|
|
1786
|
+
}
|
|
1787
|
+
if (
|
|
1788
|
+
release.releaseDigest !== expected ||
|
|
1789
|
+
release.tenantId !== this.tenantId
|
|
1790
|
+
) {
|
|
1791
|
+
throw failure("SKILL_RELEASE_CORRUPT", "release path digest differs");
|
|
1792
|
+
}
|
|
1793
|
+
return release;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
#readStateRaw(name) {
|
|
1797
|
+
const normalizedName = skillName(name);
|
|
1798
|
+
try {
|
|
1799
|
+
const state = verifyState(
|
|
1800
|
+
this.#readJson(
|
|
1801
|
+
this.#statePath(normalizedName),
|
|
1802
|
+
"SKILL_RELEASE_STATE_CORRUPT",
|
|
1803
|
+
),
|
|
1804
|
+
);
|
|
1805
|
+
if (state.skillName !== normalizedName) {
|
|
1806
|
+
throw failure(
|
|
1807
|
+
"SKILL_RELEASE_STATE_CORRUPT",
|
|
1808
|
+
"state Skill name differs",
|
|
1809
|
+
);
|
|
1810
|
+
}
|
|
1811
|
+
if (state.tenantId !== this.tenantId) {
|
|
1812
|
+
throw failure(
|
|
1813
|
+
"SKILL_RELEASE_STATE_CORRUPT",
|
|
1814
|
+
"state belongs to another tenant",
|
|
1815
|
+
);
|
|
1816
|
+
}
|
|
1817
|
+
return state;
|
|
1818
|
+
} catch (error) {
|
|
1819
|
+
if (error?.code === "ENOENT") {
|
|
1820
|
+
return initialState(normalizedName, this.tenantId);
|
|
1821
|
+
}
|
|
1822
|
+
throw error;
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
#query(transactionId, expected = {}) {
|
|
1827
|
+
let projection;
|
|
1828
|
+
try {
|
|
1829
|
+
projection = this.#ledgerQuery(transactionId);
|
|
1830
|
+
} catch (cause) {
|
|
1831
|
+
throw failure(
|
|
1832
|
+
"SKILL_RELEASE_LEDGER_UNAVAILABLE",
|
|
1833
|
+
"transaction ledger query failed closed",
|
|
1834
|
+
{ cause, transactionId },
|
|
1835
|
+
);
|
|
1836
|
+
}
|
|
1837
|
+
if (projection && typeof projection.then === "function") {
|
|
1838
|
+
throw failure(
|
|
1839
|
+
"SKILL_RELEASE_LEDGER_INVALID",
|
|
1840
|
+
"transaction ledger query must be synchronous for constructor recovery",
|
|
1841
|
+
);
|
|
1842
|
+
}
|
|
1843
|
+
return verifyLedgerProjection(projection, { transactionId, ...expected });
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
#verifyCommittedState(state) {
|
|
1847
|
+
const projection = this.#query(state.transactionId, {
|
|
1848
|
+
authorityReceiptDigest: state.authorityReceiptDigest,
|
|
1849
|
+
intentDigest: undefined,
|
|
1850
|
+
pointerDigest: state.stateDigest,
|
|
1851
|
+
revision: state.revision,
|
|
1852
|
+
skillName: state.skillName,
|
|
1853
|
+
stateDigest: state.stateDigest,
|
|
1854
|
+
});
|
|
1855
|
+
if (
|
|
1856
|
+
projection.status !== "committed" ||
|
|
1857
|
+
projection.authorityReceiptDigest !== state.authorityReceiptDigest ||
|
|
1858
|
+
projection.current !== true ||
|
|
1859
|
+
projection.stateDigest !== state.stateDigest ||
|
|
1860
|
+
projection.pointerDigest !== state.stateDigest
|
|
1861
|
+
) {
|
|
1862
|
+
throw failure(
|
|
1863
|
+
"SKILL_RELEASE_STATE_UNAUTHENTICATED",
|
|
1864
|
+
"active pointer is not finalized by the trusted transaction ledger",
|
|
1865
|
+
);
|
|
1866
|
+
}
|
|
1867
|
+
const active = this.readRelease(state.activeReleaseDigest);
|
|
1868
|
+
const lkg = this.readRelease(state.lastKnownGoodReleaseDigest);
|
|
1869
|
+
if (
|
|
1870
|
+
active.skillName !== state.skillName ||
|
|
1871
|
+
lkg.skillName !== state.skillName ||
|
|
1872
|
+
active.tenantId !== this.tenantId ||
|
|
1873
|
+
lkg.tenantId !== this.tenantId ||
|
|
1874
|
+
state.tenantId !== this.tenantId ||
|
|
1875
|
+
active.dependencyLockDigest !== state.dependencyLockDigest
|
|
1876
|
+
) {
|
|
1877
|
+
throw failure(
|
|
1878
|
+
"SKILL_RELEASE_STATE_CORRUPT",
|
|
1879
|
+
"state release bindings differ",
|
|
1880
|
+
);
|
|
1881
|
+
}
|
|
1882
|
+
return state;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
readState(name) {
|
|
1886
|
+
const state = this.#readStateRaw(name);
|
|
1887
|
+
return state.revision === 0 ? state : this.#verifyCommittedState(state);
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
readActive(name) {
|
|
1891
|
+
const state = this.readState(name);
|
|
1892
|
+
return state.revision === 0
|
|
1893
|
+
? null
|
|
1894
|
+
: deepFreeze({
|
|
1895
|
+
state,
|
|
1896
|
+
release: this.readRelease(state.activeReleaseDigest),
|
|
1897
|
+
});
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
pinActive(name) {
|
|
1901
|
+
const active = this.readActive(name);
|
|
1902
|
+
if (!active)
|
|
1903
|
+
throw failure("SKILL_RELEASE_NOT_ACTIVE", "Skill has no active release");
|
|
1904
|
+
const pin = Object.freeze(Object.create(null));
|
|
1905
|
+
this.#pins.set(pin, {
|
|
1906
|
+
authorityReceiptDigest: active.state.authorityReceiptDigest,
|
|
1907
|
+
releaseDigest: active.release.releaseDigest,
|
|
1908
|
+
stateDigest: active.state.stateDigest,
|
|
1909
|
+
tenantId: this.tenantId,
|
|
1910
|
+
transactionId: active.state.transactionId,
|
|
1911
|
+
});
|
|
1912
|
+
return pin;
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
readPinned(pin) {
|
|
1916
|
+
const binding =
|
|
1917
|
+
pin && typeof pin === "object" ? this.#pins.get(pin) : undefined;
|
|
1918
|
+
if (!binding || binding.tenantId !== this.tenantId) {
|
|
1919
|
+
throw failure(
|
|
1920
|
+
"SKILL_RELEASE_PIN_INVALID",
|
|
1921
|
+
"session pin is forged, serialized, or belongs to another registry instance",
|
|
1922
|
+
);
|
|
1923
|
+
}
|
|
1924
|
+
const projection = this.#query(binding.transactionId, {
|
|
1925
|
+
authorityReceiptDigest: binding.authorityReceiptDigest,
|
|
1926
|
+
pointerDigest: binding.stateDigest,
|
|
1927
|
+
stateDigest: binding.stateDigest,
|
|
1928
|
+
});
|
|
1929
|
+
if (projection.status !== "committed") {
|
|
1930
|
+
throw failure(
|
|
1931
|
+
"SKILL_RELEASE_PIN_INVALID",
|
|
1932
|
+
"pinned activation is not committed",
|
|
1933
|
+
);
|
|
1934
|
+
}
|
|
1935
|
+
return this.readRelease(binding.releaseDigest);
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
#owner(kind, token, { skill = null, transactionId = null, fence = 0 } = {}) {
|
|
1939
|
+
const clock = this.#clock();
|
|
1940
|
+
const expiryMs =
|
|
1941
|
+
kind === "recovery"
|
|
1942
|
+
? Math.max(this.#leaseTtlMs, DEFAULT_LEASE_TTL_MS)
|
|
1943
|
+
: this.#leaseTtlMs;
|
|
1944
|
+
return deepFreeze({
|
|
1945
|
+
createdAt: clock.toISOString(),
|
|
1946
|
+
expiresAt: new Date(clock.getTime() + expiryMs).toISOString(),
|
|
1947
|
+
fence,
|
|
1948
|
+
heartbeatAt: clock.toISOString(),
|
|
1949
|
+
host: os.hostname(),
|
|
1950
|
+
kind,
|
|
1951
|
+
pid: process.pid,
|
|
1952
|
+
schema: LOCK_OWNER_SCHEMA,
|
|
1953
|
+
skillName: skill,
|
|
1954
|
+
tenantId: this.tenantId,
|
|
1955
|
+
token,
|
|
1956
|
+
transactionId,
|
|
1957
|
+
});
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
#verifyOwner(value) {
|
|
1961
|
+
if (
|
|
1962
|
+
value &&
|
|
1963
|
+
typeof value === "object" &&
|
|
1964
|
+
!utilTypes.isProxy(value) &&
|
|
1965
|
+
Object.getOwnPropertyDescriptor(value, "schema")?.value ===
|
|
1966
|
+
LEGACY_LOCK_OWNER_SCHEMA
|
|
1967
|
+
) {
|
|
1968
|
+
throw migrationRequired(
|
|
1969
|
+
"legacy release lock owner requires explicit tenant-scoped migration",
|
|
1970
|
+
);
|
|
1971
|
+
}
|
|
1972
|
+
const keys = new Set([
|
|
1973
|
+
"createdAt",
|
|
1974
|
+
"expiresAt",
|
|
1975
|
+
"fence",
|
|
1976
|
+
"heartbeatAt",
|
|
1977
|
+
"host",
|
|
1978
|
+
"kind",
|
|
1979
|
+
"pid",
|
|
1980
|
+
"schema",
|
|
1981
|
+
"skillName",
|
|
1982
|
+
"tenantId",
|
|
1983
|
+
"token",
|
|
1984
|
+
"transactionId",
|
|
1985
|
+
]);
|
|
1986
|
+
assertExactKeys(value, keys, "lock owner", "SKILL_RELEASE_LEASE_INVALID");
|
|
1987
|
+
if (
|
|
1988
|
+
value.schema !== LOCK_OWNER_SCHEMA ||
|
|
1989
|
+
!["recovery", "skill"].includes(value.kind) ||
|
|
1990
|
+
tenantId(value.tenantId, "owner tenantId") !== this.tenantId ||
|
|
1991
|
+
!TOKEN_PATTERN.test(value.token) ||
|
|
1992
|
+
!Number.isSafeInteger(value.pid) ||
|
|
1993
|
+
value.pid < 1 ||
|
|
1994
|
+
typeof value.host !== "string" ||
|
|
1995
|
+
!Number.isSafeInteger(value.fence) ||
|
|
1996
|
+
value.fence < 0
|
|
1997
|
+
) {
|
|
1998
|
+
throw failure("SKILL_RELEASE_LEASE_INVALID", "lock owner is invalid");
|
|
1999
|
+
}
|
|
2000
|
+
for (const field of ["createdAt", "heartbeatAt", "expiresAt"]) {
|
|
2001
|
+
const parsed = new Date(value[field]);
|
|
2002
|
+
if (
|
|
2003
|
+
!Number.isFinite(parsed.getTime()) ||
|
|
2004
|
+
parsed.toISOString() !== value[field]
|
|
2005
|
+
) {
|
|
2006
|
+
throw failure(
|
|
2007
|
+
"SKILL_RELEASE_LEASE_INVALID",
|
|
2008
|
+
`owner ${field} is invalid`,
|
|
2009
|
+
);
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
if (value.kind === "skill") {
|
|
2013
|
+
skillName(value.skillName);
|
|
2014
|
+
digest(value.transactionId, "owner.transactionId");
|
|
2015
|
+
safeInteger(value.fence, "owner.fence", { minimum: 1 });
|
|
2016
|
+
} else if (
|
|
2017
|
+
value.skillName !== null ||
|
|
2018
|
+
value.transactionId !== null ||
|
|
2019
|
+
value.fence !== 0
|
|
2020
|
+
) {
|
|
2021
|
+
throw failure("SKILL_RELEASE_LEASE_INVALID", "recovery owner is invalid");
|
|
2022
|
+
}
|
|
2023
|
+
return deepFreeze({ ...value });
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
#readOwner(lockPath) {
|
|
2027
|
+
return this.#verifyOwner(
|
|
2028
|
+
this.#readJson(
|
|
2029
|
+
path.join(lockPath, "owner.json"),
|
|
2030
|
+
"SKILL_RELEASE_LEASE_INVALID",
|
|
2031
|
+
),
|
|
2032
|
+
);
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
#ownerExpired(owner) {
|
|
2036
|
+
return new Date(owner.expiresAt).getTime() <= this.#clock().getTime();
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
#ownerStale(owner) {
|
|
2040
|
+
if (this.#ownerExpired(owner)) return true;
|
|
2041
|
+
if (owner.host !== os.hostname() || owner.pid === process.pid) return false;
|
|
2042
|
+
try {
|
|
2043
|
+
process.kill(owner.pid, 0);
|
|
2044
|
+
return false;
|
|
2045
|
+
} catch (error) {
|
|
2046
|
+
return error?.code === "ESRCH";
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
#createLock(lockPath, owner) {
|
|
2051
|
+
this.#fs.mkdirSync(lockPath, { mode: 0o700 });
|
|
2052
|
+
let descriptor = null;
|
|
2053
|
+
try {
|
|
2054
|
+
descriptor = this.#fs.openSync(
|
|
2055
|
+
path.join(lockPath, "owner.json"),
|
|
2056
|
+
"wx",
|
|
2057
|
+
0o600,
|
|
2058
|
+
);
|
|
2059
|
+
const bytes = serialize(owner);
|
|
2060
|
+
this.#fs.writeFileSync(descriptor, bytes);
|
|
2061
|
+
this.#fs.fsyncSync(descriptor);
|
|
2062
|
+
if (this.#fs.fstatSync(descriptor).size !== bytes.length) {
|
|
2063
|
+
throw failure(
|
|
2064
|
+
"SKILL_RELEASE_WRITE_FAILED",
|
|
2065
|
+
"lock owner write was incomplete",
|
|
2066
|
+
);
|
|
2067
|
+
}
|
|
2068
|
+
this.#fs.closeSync(descriptor);
|
|
2069
|
+
descriptor = null;
|
|
2070
|
+
fsyncDirectory(this.#fs, lockPath);
|
|
2071
|
+
fsyncDirectory(this.#fs, this.#directories.locks.path);
|
|
2072
|
+
} catch (error) {
|
|
2073
|
+
if (descriptor !== null) this.#fs.closeSync(descriptor);
|
|
2074
|
+
this.#fs.rmSync(lockPath, { recursive: true, force: true });
|
|
2075
|
+
throw error;
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
#removeLock(lockPath, expectedToken = null) {
|
|
2080
|
+
let owner;
|
|
2081
|
+
try {
|
|
2082
|
+
owner = this.#readOwner(lockPath);
|
|
2083
|
+
} catch (error) {
|
|
2084
|
+
if (error?.code === "ENOENT") return;
|
|
2085
|
+
throw error;
|
|
2086
|
+
}
|
|
2087
|
+
if (expectedToken !== null && owner.token !== expectedToken) {
|
|
2088
|
+
throw failure("SKILL_RELEASE_LEASE_INVALID", "lock ownership changed");
|
|
2089
|
+
}
|
|
2090
|
+
const tombstone = this.#path(
|
|
2091
|
+
"locks",
|
|
2092
|
+
`.released-${process.pid}-${owner.token}`,
|
|
2093
|
+
);
|
|
2094
|
+
try {
|
|
2095
|
+
this.#fs.renameSync(lockPath, tombstone);
|
|
2096
|
+
} catch (error) {
|
|
2097
|
+
if (error?.code === "ENOENT") return;
|
|
2098
|
+
throw error;
|
|
2099
|
+
}
|
|
2100
|
+
fsyncDirectory(this.#fs, this.#directories.locks.path);
|
|
2101
|
+
this.#fs.rmSync(tombstone, { recursive: true, force: true });
|
|
2102
|
+
fsyncDirectory(this.#fs, this.#directories.locks.path);
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
#acquireRecoveryLock() {
|
|
2106
|
+
const lockPath = this.#recoveryLockPath();
|
|
2107
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
2108
|
+
const token = this.#token();
|
|
2109
|
+
const owner = this.#owner("recovery", token);
|
|
2110
|
+
try {
|
|
2111
|
+
this.#createLock(lockPath, owner);
|
|
2112
|
+
return deepFreeze({ lockPath, token });
|
|
2113
|
+
} catch (error) {
|
|
2114
|
+
if (error?.code !== "EEXIST") throw error;
|
|
2115
|
+
const existing = this.#readOwner(lockPath);
|
|
2116
|
+
if (!this.#ownerStale(existing)) {
|
|
2117
|
+
throw failure(
|
|
2118
|
+
"SKILL_RELEASE_RECOVERY_BUSY",
|
|
2119
|
+
"another process owns the recovery mutex",
|
|
2120
|
+
);
|
|
2121
|
+
}
|
|
2122
|
+
this.#removeLock(lockPath, existing.token);
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
throw failure(
|
|
2126
|
+
"SKILL_RELEASE_RECOVERY_BUSY",
|
|
2127
|
+
"recovery mutex is unavailable",
|
|
2128
|
+
);
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
#withRecoveryLock(callback) {
|
|
2132
|
+
const lock = this.#acquireRecoveryLock();
|
|
2133
|
+
try {
|
|
2134
|
+
return callback();
|
|
2135
|
+
} finally {
|
|
2136
|
+
this.#removeLock(lock.lockPath, lock.token);
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
#renewLease(lease) {
|
|
2141
|
+
const current = this.#readOwner(lease.lockPath);
|
|
2142
|
+
if (
|
|
2143
|
+
current.token !== lease.token ||
|
|
2144
|
+
current.transactionId !== lease.transactionId ||
|
|
2145
|
+
current.fence !== lease.fence
|
|
2146
|
+
) {
|
|
2147
|
+
throw failure(
|
|
2148
|
+
"SKILL_RELEASE_LEASE_INVALID",
|
|
2149
|
+
"lease was fenced by another owner",
|
|
2150
|
+
);
|
|
2151
|
+
}
|
|
2152
|
+
const clock = this.#clock();
|
|
2153
|
+
const renewed = deepFreeze({
|
|
2154
|
+
...current,
|
|
2155
|
+
expiresAt: new Date(clock.getTime() + this.#leaseTtlMs).toISOString(),
|
|
2156
|
+
heartbeatAt: clock.toISOString(),
|
|
2157
|
+
});
|
|
2158
|
+
this.#atomicWrite(
|
|
2159
|
+
"locks",
|
|
2160
|
+
path.join(lease.lockPath, "owner.json"),
|
|
2161
|
+
renewed,
|
|
2162
|
+
);
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
#startHeartbeat(lease) {
|
|
2166
|
+
const interval = Math.max(10, Math.floor(this.#leaseTtlMs / 3));
|
|
2167
|
+
const timer = setInterval(() => {
|
|
2168
|
+
try {
|
|
2169
|
+
this.#renewLease(lease);
|
|
2170
|
+
} catch {
|
|
2171
|
+
// The foreground CAS and owner verification remain fail-closed.
|
|
2172
|
+
}
|
|
2173
|
+
}, interval);
|
|
2174
|
+
timer.unref?.();
|
|
2175
|
+
return timer;
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
#acquireLease(name, transactionId, expectedRevision, expectedParentDigest) {
|
|
2179
|
+
const journalPath = this.#journalPath(name);
|
|
2180
|
+
if (this.#exists(journalPath)) {
|
|
2181
|
+
this.#withRecoveryLock(() => this.#recoverSkill(name));
|
|
2182
|
+
if (this.#exists(journalPath)) {
|
|
2183
|
+
throw failure(
|
|
2184
|
+
"SKILL_RELEASE_LEASE_BUSY",
|
|
2185
|
+
"a live transition journal owns this Skill",
|
|
2186
|
+
);
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
const lockPath = this.#leasePath(name);
|
|
2190
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
2191
|
+
const state = this.readState(name);
|
|
2192
|
+
const active =
|
|
2193
|
+
state.activeReleaseDigest === null
|
|
2194
|
+
? null
|
|
2195
|
+
: this.readRelease(state.activeReleaseDigest);
|
|
2196
|
+
const actualParent = active?.contentDigest ?? EMPTY_SKILL_ACTIVE_DIGEST;
|
|
2197
|
+
if (
|
|
2198
|
+
state.revision !== expectedRevision ||
|
|
2199
|
+
actualParent !== expectedParentDigest
|
|
2200
|
+
) {
|
|
2201
|
+
throw failure(
|
|
2202
|
+
"SKILL_RELEASE_CAS_MISMATCH",
|
|
2203
|
+
"active parent CAS is stale",
|
|
2204
|
+
{
|
|
2205
|
+
actualParentDigest: actualParent,
|
|
2206
|
+
actualRevision: state.revision,
|
|
2207
|
+
},
|
|
2208
|
+
);
|
|
2209
|
+
}
|
|
2210
|
+
const token = this.#token();
|
|
2211
|
+
const owner = this.#owner("skill", token, {
|
|
2212
|
+
skill: name,
|
|
2213
|
+
transactionId,
|
|
2214
|
+
fence: state.fence + 1,
|
|
2215
|
+
});
|
|
2216
|
+
try {
|
|
2217
|
+
this.#createLock(lockPath, owner);
|
|
2218
|
+
const lease = deepFreeze({
|
|
2219
|
+
fence: state.fence + 1,
|
|
2220
|
+
lockPath,
|
|
2221
|
+
token,
|
|
2222
|
+
transactionId,
|
|
2223
|
+
});
|
|
2224
|
+
return { lease, state, heartbeat: this.#startHeartbeat(lease) };
|
|
2225
|
+
} catch (error) {
|
|
2226
|
+
if (error?.code !== "EEXIST") throw error;
|
|
2227
|
+
const existing = this.#readOwner(lockPath);
|
|
2228
|
+
if (!this.#ownerStale(existing)) {
|
|
2229
|
+
throw failure("SKILL_RELEASE_LEASE_BUSY", "Skill lease is live");
|
|
2230
|
+
}
|
|
2231
|
+
this.#withRecoveryLock(() => this.#recoverSkill(name));
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
throw failure(
|
|
2235
|
+
"SKILL_RELEASE_LEASE_BUSY",
|
|
2236
|
+
"Skill lease could not be acquired",
|
|
2237
|
+
);
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
#releaseLease(lease) {
|
|
2241
|
+
this.#removeLock(lease.lockPath, lease.token);
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
#buildIntent(payload, previousState, nextState) {
|
|
2245
|
+
const core = {
|
|
2246
|
+
authorityReceipt: payload.authorityReceipt,
|
|
2247
|
+
authorityReceiptDigest: payload.authorityReceipt.receiptDigest,
|
|
2248
|
+
candidateId: payload.candidate?.candidateId ?? null,
|
|
2249
|
+
dependencyLockDigest: payload.dependencyLockDigest,
|
|
2250
|
+
expectedParentDigest: payload.expectedParentDigest,
|
|
2251
|
+
expectedRevision: payload.expectedRevision,
|
|
2252
|
+
nextStateDigest: nextState.stateDigest,
|
|
2253
|
+
mutationRequest: payload.mutationRequest,
|
|
2254
|
+
operation: payload.operation,
|
|
2255
|
+
operationId: payload.operationId,
|
|
2256
|
+
pointerDigest: nextState.stateDigest,
|
|
2257
|
+
previousStateDigest:
|
|
2258
|
+
previousState.revision === 0 ? null : previousState.stateDigest,
|
|
2259
|
+
receiptDigests: payload.receiptDigests,
|
|
2260
|
+
requestDigest: payload.requestDigest,
|
|
2261
|
+
schema: INTENT_SCHEMA,
|
|
2262
|
+
skillName: payload.skillName,
|
|
2263
|
+
targetReleaseDigest: nextState.activeReleaseDigest,
|
|
2264
|
+
transactionId: nextState.transactionId,
|
|
2265
|
+
transitionSubjectDigest: payload.transitionSubjectDigest,
|
|
2266
|
+
};
|
|
2267
|
+
return deepFreeze({
|
|
2268
|
+
...core,
|
|
2269
|
+
intentDigest: domainDigest(INTENT_DOMAIN, core),
|
|
2270
|
+
});
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
#verifyIntent(value) {
|
|
2274
|
+
const keys = new Set([
|
|
2275
|
+
"authorityReceiptDigest",
|
|
2276
|
+
"authorityReceipt",
|
|
2277
|
+
"candidateId",
|
|
2278
|
+
"dependencyLockDigest",
|
|
2279
|
+
"expectedParentDigest",
|
|
2280
|
+
"expectedRevision",
|
|
2281
|
+
"mutationRequest",
|
|
2282
|
+
"intentDigest",
|
|
2283
|
+
"nextStateDigest",
|
|
2284
|
+
"operation",
|
|
2285
|
+
"operationId",
|
|
2286
|
+
"pointerDigest",
|
|
2287
|
+
"previousStateDigest",
|
|
2288
|
+
"receiptDigests",
|
|
2289
|
+
"requestDigest",
|
|
2290
|
+
"schema",
|
|
2291
|
+
"skillName",
|
|
2292
|
+
"targetReleaseDigest",
|
|
2293
|
+
"transactionId",
|
|
2294
|
+
"transitionSubjectDigest",
|
|
2295
|
+
]);
|
|
2296
|
+
assertExactKeys(
|
|
2297
|
+
value,
|
|
2298
|
+
keys,
|
|
2299
|
+
"transition intent",
|
|
2300
|
+
"SKILL_RELEASE_JOURNAL_CORRUPT",
|
|
2301
|
+
);
|
|
2302
|
+
const core = { ...value };
|
|
2303
|
+
delete core.intentDigest;
|
|
2304
|
+
if (
|
|
2305
|
+
core.schema !== INTENT_SCHEMA ||
|
|
2306
|
+
!["promote", "rollback"].includes(core.operation) ||
|
|
2307
|
+
domainDigest(INTENT_DOMAIN, core) !== value.intentDigest
|
|
2308
|
+
) {
|
|
2309
|
+
throw failure(
|
|
2310
|
+
"SKILL_RELEASE_JOURNAL_CORRUPT",
|
|
2311
|
+
"intent digest is invalid",
|
|
2312
|
+
);
|
|
2313
|
+
}
|
|
2314
|
+
for (const field of [
|
|
2315
|
+
"authorityReceiptDigest",
|
|
2316
|
+
"dependencyLockDigest",
|
|
2317
|
+
"expectedParentDigest",
|
|
2318
|
+
"nextStateDigest",
|
|
2319
|
+
"pointerDigest",
|
|
2320
|
+
"requestDigest",
|
|
2321
|
+
"targetReleaseDigest",
|
|
2322
|
+
"transactionId",
|
|
2323
|
+
"transitionSubjectDigest",
|
|
2324
|
+
"intentDigest",
|
|
2325
|
+
]) {
|
|
2326
|
+
digest(value[field], `intent.${field}`);
|
|
2327
|
+
}
|
|
2328
|
+
digest(value.previousStateDigest, "intent.previousStateDigest", {
|
|
2329
|
+
nullable: true,
|
|
2330
|
+
});
|
|
2331
|
+
digest(value.candidateId, "intent.candidateId", { nullable: true });
|
|
2332
|
+
safeInteger(value.expectedRevision, "intent.expectedRevision");
|
|
2333
|
+
skillName(value.skillName);
|
|
2334
|
+
boundedString(value.operationId, "intent.operationId");
|
|
2335
|
+
normalizeReceiptDigests(value.receiptDigests);
|
|
2336
|
+
const authorityReceipt = verifySkillMutationConsumptionReceipt(
|
|
2337
|
+
value.authorityReceipt,
|
|
2338
|
+
);
|
|
2339
|
+
const mutationRequest = verifySkillMutationRequest(value.mutationRequest);
|
|
2340
|
+
if (
|
|
2341
|
+
authorityReceipt.receiptDigest !== value.authorityReceiptDigest ||
|
|
2342
|
+
authorityReceipt.tenantId !== this.tenantId ||
|
|
2343
|
+
authorityReceipt.requestDigest !== value.requestDigest ||
|
|
2344
|
+
authorityReceipt.operationId !== value.operationId ||
|
|
2345
|
+
authorityReceipt.operation !== value.operation ||
|
|
2346
|
+
authorityReceipt.transitionSubjectDigest !==
|
|
2347
|
+
value.transitionSubjectDigest ||
|
|
2348
|
+
authorityReceipt.skillName !== value.skillName ||
|
|
2349
|
+
authorityReceipt.expectedTargetDigest !== value.expectedParentDigest ||
|
|
2350
|
+
authorityReceipt.expectedTargetRevision !== value.expectedRevision
|
|
2351
|
+
) {
|
|
2352
|
+
throw failure(
|
|
2353
|
+
"SKILL_RELEASE_JOURNAL_CORRUPT",
|
|
2354
|
+
"intent authority receipt bindings are invalid",
|
|
2355
|
+
);
|
|
2356
|
+
}
|
|
2357
|
+
verifyRequestReceiptBinding(mutationRequest, authorityReceipt);
|
|
2358
|
+
if (
|
|
2359
|
+
mutationRequest.requestDigest !== value.requestDigest ||
|
|
2360
|
+
mutationRequest.tenantId !== this.tenantId ||
|
|
2361
|
+
mutationRequest.operation !== value.operation ||
|
|
2362
|
+
mutationRequest.transitionSubjectDigest !==
|
|
2363
|
+
value.transitionSubjectDigest ||
|
|
2364
|
+
canonicalJson(receiptDigestsFromRequest(mutationRequest)) !==
|
|
2365
|
+
canonicalJson(value.receiptDigests)
|
|
2366
|
+
) {
|
|
2367
|
+
throw failure(
|
|
2368
|
+
"SKILL_RELEASE_JOURNAL_CORRUPT",
|
|
2369
|
+
"intent six-receipt bindings are invalid",
|
|
2370
|
+
);
|
|
2371
|
+
}
|
|
2372
|
+
const expectedTransitionSubjectDigest =
|
|
2373
|
+
digestSkillMutationTransitionSubject({
|
|
2374
|
+
tenantId: mutationRequest.tenantId,
|
|
2375
|
+
skillName: value.skillName,
|
|
2376
|
+
operation: value.operation,
|
|
2377
|
+
candidateId: value.candidateId,
|
|
2378
|
+
rollbackTargetReleaseDigest:
|
|
2379
|
+
value.operation === SKILL_MUTATION_OPERATIONS.ROLLBACK
|
|
2380
|
+
? value.targetReleaseDigest
|
|
2381
|
+
: null,
|
|
2382
|
+
dependencyLockDigest: value.dependencyLockDigest,
|
|
2383
|
+
expectedActiveContentDigest: value.expectedParentDigest,
|
|
2384
|
+
expectedActiveRevision: value.expectedRevision,
|
|
2385
|
+
});
|
|
2386
|
+
if (value.transitionSubjectDigest !== expectedTransitionSubjectDigest) {
|
|
2387
|
+
throw failure(
|
|
2388
|
+
"SKILL_RELEASE_JOURNAL_CORRUPT",
|
|
2389
|
+
"intent transition subject is not bound to its operation target",
|
|
2390
|
+
);
|
|
2391
|
+
}
|
|
2392
|
+
return deepFreeze({ ...value });
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
#journal(value) {
|
|
2396
|
+
const core = { ...value };
|
|
2397
|
+
delete core.journalDigest;
|
|
2398
|
+
return deepFreeze({
|
|
2399
|
+
...core,
|
|
2400
|
+
journalDigest: domainDigest(JOURNAL_DOMAIN, core),
|
|
2401
|
+
});
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
#persistJournal(value) {
|
|
2405
|
+
const journal = this.#journal(value);
|
|
2406
|
+
this.#atomicWrite(
|
|
2407
|
+
"journals",
|
|
2408
|
+
this.#journalPath(journal.skillName),
|
|
2409
|
+
journal,
|
|
2410
|
+
);
|
|
2411
|
+
return journal;
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
#verifyJournal(value) {
|
|
2415
|
+
if (
|
|
2416
|
+
value &&
|
|
2417
|
+
typeof value === "object" &&
|
|
2418
|
+
!utilTypes.isProxy(value) &&
|
|
2419
|
+
Object.getOwnPropertyDescriptor(value, "schema")?.value ===
|
|
2420
|
+
LEGACY_JOURNAL_SCHEMA
|
|
2421
|
+
) {
|
|
2422
|
+
throw migrationRequired(
|
|
2423
|
+
"legacy release journal requires explicit tenant-scoped migration",
|
|
2424
|
+
);
|
|
2425
|
+
}
|
|
2426
|
+
const keys = new Set([
|
|
2427
|
+
"intent",
|
|
2428
|
+
"journalDigest",
|
|
2429
|
+
"leaseToken",
|
|
2430
|
+
"nextState",
|
|
2431
|
+
"phase",
|
|
2432
|
+
"prepareReceipt",
|
|
2433
|
+
"previousState",
|
|
2434
|
+
"schema",
|
|
2435
|
+
"skillName",
|
|
2436
|
+
"stagedFile",
|
|
2437
|
+
"tenantId",
|
|
2438
|
+
"transactionId",
|
|
2439
|
+
]);
|
|
2440
|
+
assertExactKeys(
|
|
2441
|
+
value,
|
|
2442
|
+
keys,
|
|
2443
|
+
"transition journal",
|
|
2444
|
+
"SKILL_RELEASE_JOURNAL_CORRUPT",
|
|
2445
|
+
);
|
|
2446
|
+
const core = { ...value };
|
|
2447
|
+
delete core.journalDigest;
|
|
2448
|
+
if (
|
|
2449
|
+
core.schema !== JOURNAL_SCHEMA ||
|
|
2450
|
+
tenantId(core.tenantId, "journal tenantId") !== this.tenantId ||
|
|
2451
|
+
!["journaled", "prepared", "pointer-written"].includes(core.phase) ||
|
|
2452
|
+
!TOKEN_PATTERN.test(core.leaseToken) ||
|
|
2453
|
+
!TEMP_PATTERN.test(core.stagedFile) ||
|
|
2454
|
+
domainDigest(JOURNAL_DOMAIN, core) !== value.journalDigest
|
|
2455
|
+
) {
|
|
2456
|
+
throw failure(
|
|
2457
|
+
"SKILL_RELEASE_JOURNAL_CORRUPT",
|
|
2458
|
+
"journal digest is invalid",
|
|
2459
|
+
);
|
|
2460
|
+
}
|
|
2461
|
+
const intent = this.#verifyIntent(core.intent);
|
|
2462
|
+
const nextState = verifyState(core.nextState);
|
|
2463
|
+
const previousState =
|
|
2464
|
+
core.previousState === null ? null : verifyState(core.previousState);
|
|
2465
|
+
if (
|
|
2466
|
+
intent.transactionId !== core.transactionId ||
|
|
2467
|
+
nextState.transactionId !== core.transactionId ||
|
|
2468
|
+
nextState.skillName !== core.skillName ||
|
|
2469
|
+
nextState.tenantId !== this.tenantId ||
|
|
2470
|
+
intent.skillName !== core.skillName ||
|
|
2471
|
+
nextState.stateDigest !== intent.nextStateDigest ||
|
|
2472
|
+
nextState.stateDigest !== intent.pointerDigest ||
|
|
2473
|
+
nextState.activeReleaseDigest !== intent.targetReleaseDigest ||
|
|
2474
|
+
nextState.authorityReceiptDigest !== intent.authorityReceiptDigest ||
|
|
2475
|
+
nextState.dependencyLockDigest !== intent.dependencyLockDigest ||
|
|
2476
|
+
nextState.revision !== intent.expectedRevision + 1 ||
|
|
2477
|
+
(previousState === null
|
|
2478
|
+
? intent.previousStateDigest !== null || intent.expectedRevision !== 0
|
|
2479
|
+
: previousState.skillName !== core.skillName ||
|
|
2480
|
+
previousState.tenantId !== this.tenantId ||
|
|
2481
|
+
previousState.stateDigest !== intent.previousStateDigest ||
|
|
2482
|
+
previousState.revision !== intent.expectedRevision)
|
|
2483
|
+
) {
|
|
2484
|
+
throw failure(
|
|
2485
|
+
"SKILL_RELEASE_JOURNAL_CORRUPT",
|
|
2486
|
+
"journal state transition bindings are invalid",
|
|
2487
|
+
);
|
|
2488
|
+
}
|
|
2489
|
+
if (core.prepareReceipt !== null) {
|
|
2490
|
+
verifyLedgerProjection(core.prepareReceipt, {
|
|
2491
|
+
authorityReceiptDigest: intent.authorityReceiptDigest,
|
|
2492
|
+
intentDigest: intent.intentDigest,
|
|
2493
|
+
transactionId: intent.transactionId,
|
|
2494
|
+
});
|
|
2495
|
+
if (core.prepareReceipt.status !== "prepared") {
|
|
2496
|
+
throw failure(
|
|
2497
|
+
"SKILL_RELEASE_JOURNAL_CORRUPT",
|
|
2498
|
+
"journal prepare receipt has the wrong status",
|
|
2499
|
+
);
|
|
2500
|
+
}
|
|
2501
|
+
} else if (core.phase !== "journaled") {
|
|
2502
|
+
throw failure(
|
|
2503
|
+
"SKILL_RELEASE_JOURNAL_CORRUPT",
|
|
2504
|
+
"prepared journal is missing its projection",
|
|
2505
|
+
);
|
|
2506
|
+
}
|
|
2507
|
+
return deepFreeze({ ...core, journalDigest: value.journalDigest });
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
#readJournal(name) {
|
|
2511
|
+
return this.#verifyJournal(
|
|
2512
|
+
this.#readJson(this.#journalPath(name), "SKILL_RELEASE_JOURNAL_CORRUPT"),
|
|
2513
|
+
);
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
#prepare(intent) {
|
|
2517
|
+
let projection;
|
|
2518
|
+
try {
|
|
2519
|
+
projection = this.#ledgerPrepare(intent);
|
|
2520
|
+
} catch (cause) {
|
|
2521
|
+
throw failure(
|
|
2522
|
+
"SKILL_RELEASE_LEDGER_PREPARE_FAILED",
|
|
2523
|
+
"transaction ledger prepare failed closed",
|
|
2524
|
+
{ cause, transactionId: intent.transactionId },
|
|
2525
|
+
);
|
|
2526
|
+
}
|
|
2527
|
+
if (projection && typeof projection.then === "function") {
|
|
2528
|
+
throw failure(
|
|
2529
|
+
"SKILL_RELEASE_LEDGER_INVALID",
|
|
2530
|
+
"transaction ledger prepare must be synchronous",
|
|
2531
|
+
);
|
|
2532
|
+
}
|
|
2533
|
+
const verified = verifyLedgerProjection(projection, {
|
|
2534
|
+
authorityReceiptDigest: intent.authorityReceiptDigest,
|
|
2535
|
+
intentDigest: intent.intentDigest,
|
|
2536
|
+
transactionId: intent.transactionId,
|
|
2537
|
+
});
|
|
2538
|
+
if (verified.status !== "prepared") {
|
|
2539
|
+
throw failure(
|
|
2540
|
+
"SKILL_RELEASE_LEDGER_INVALID",
|
|
2541
|
+
"prepare did not return a prepared projection",
|
|
2542
|
+
);
|
|
2543
|
+
}
|
|
2544
|
+
return verified;
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
#finalize(journal, prepareReceipt) {
|
|
2548
|
+
const request = deepFreeze({
|
|
2549
|
+
authorityReceiptDigest: journal.intent.authorityReceiptDigest,
|
|
2550
|
+
expectedPrepareReceiptDigest: prepareReceipt.receiptDigest,
|
|
2551
|
+
intentDigest: journal.intent.intentDigest,
|
|
2552
|
+
pointerDigest: journal.nextState.stateDigest,
|
|
2553
|
+
revision: journal.nextState.revision,
|
|
2554
|
+
skillName: journal.nextState.skillName,
|
|
2555
|
+
stateDigest: journal.nextState.stateDigest,
|
|
2556
|
+
transactionId: journal.transactionId,
|
|
2557
|
+
});
|
|
2558
|
+
let projection;
|
|
2559
|
+
try {
|
|
2560
|
+
projection = this.#ledgerFinalize(request);
|
|
2561
|
+
} catch (cause) {
|
|
2562
|
+
throw failure(
|
|
2563
|
+
"SKILL_RELEASE_LEDGER_FINALIZE_FAILED",
|
|
2564
|
+
"transaction ledger finalize failed; recovery evidence was retained",
|
|
2565
|
+
{ cause, transactionId: journal.transactionId },
|
|
2566
|
+
);
|
|
2567
|
+
}
|
|
2568
|
+
if (projection && typeof projection.then === "function") {
|
|
2569
|
+
throw failure(
|
|
2570
|
+
"SKILL_RELEASE_LEDGER_INVALID",
|
|
2571
|
+
"transaction ledger finalize must be synchronous",
|
|
2572
|
+
);
|
|
2573
|
+
}
|
|
2574
|
+
const verified = verifyLedgerProjection(projection, {
|
|
2575
|
+
authorityReceiptDigest: journal.intent.authorityReceiptDigest,
|
|
2576
|
+
intentDigest: journal.intent.intentDigest,
|
|
2577
|
+
pointerDigest: journal.nextState.stateDigest,
|
|
2578
|
+
prepareReceiptDigest: prepareReceipt.receiptDigest,
|
|
2579
|
+
revision: journal.nextState.revision,
|
|
2580
|
+
skillName: journal.nextState.skillName,
|
|
2581
|
+
stateDigest: journal.nextState.stateDigest,
|
|
2582
|
+
transactionId: journal.transactionId,
|
|
2583
|
+
});
|
|
2584
|
+
if (verified.status !== "committed") {
|
|
2585
|
+
throw failure(
|
|
2586
|
+
"SKILL_RELEASE_LEDGER_INVALID",
|
|
2587
|
+
"finalize did not return a committed projection",
|
|
2588
|
+
);
|
|
2589
|
+
}
|
|
2590
|
+
return verified;
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
#writePointer(state, stagedFile) {
|
|
2594
|
+
const stagedPath = this.#path("staging", stagedFile);
|
|
2595
|
+
if (!this.#exists(stagedPath)) this.#writeTemporary(stagedFile, state);
|
|
2596
|
+
const expectedBytes = serialize(state);
|
|
2597
|
+
const stagedBytes = this.#readBytes(
|
|
2598
|
+
stagedPath,
|
|
2599
|
+
"SKILL_RELEASE_STATE_CORRUPT",
|
|
2600
|
+
);
|
|
2601
|
+
let verifiedStaged;
|
|
2602
|
+
try {
|
|
2603
|
+
verifiedStaged = verifyState(
|
|
2604
|
+
this.#parseCanonical(stagedBytes, "SKILL_RELEASE_STATE_CORRUPT"),
|
|
2605
|
+
);
|
|
2606
|
+
} catch (cause) {
|
|
2607
|
+
if (cause instanceof SkillReleaseRegistryError) throw cause;
|
|
2608
|
+
throw failure(
|
|
2609
|
+
"SKILL_RELEASE_STATE_CORRUPT",
|
|
2610
|
+
"staged state pointer failed verification",
|
|
2611
|
+
{ cause },
|
|
2612
|
+
);
|
|
2613
|
+
}
|
|
2614
|
+
if (
|
|
2615
|
+
verifiedStaged.tenantId !== this.tenantId ||
|
|
2616
|
+
verifiedStaged.stateDigest !== state.stateDigest ||
|
|
2617
|
+
!stagedBytes.equals(expectedBytes)
|
|
2618
|
+
) {
|
|
2619
|
+
throw failure(
|
|
2620
|
+
"SKILL_RELEASE_STATE_CORRUPT",
|
|
2621
|
+
"staged state pointer does not exactly match the journal next state",
|
|
2622
|
+
);
|
|
2623
|
+
}
|
|
2624
|
+
const staged = this.#fs.lstatSync(stagedPath);
|
|
2625
|
+
if (
|
|
2626
|
+
!staged.isFile() ||
|
|
2627
|
+
staged.isSymbolicLink() ||
|
|
2628
|
+
Number(staged.nlink) !== 1 ||
|
|
2629
|
+
!samePath(realpath(this.#fs, stagedPath), stagedPath)
|
|
2630
|
+
) {
|
|
2631
|
+
throw failure(
|
|
2632
|
+
"SKILL_RELEASE_STORE_UNSAFE",
|
|
2633
|
+
"staged state pointer is unsafe",
|
|
2634
|
+
);
|
|
2635
|
+
}
|
|
2636
|
+
this.#fs.renameSync(stagedPath, this.#statePath(state.skillName));
|
|
2637
|
+
const publishedPath = this.#statePath(state.skillName);
|
|
2638
|
+
const published = this.#fs.lstatSync(publishedPath);
|
|
2639
|
+
if (
|
|
2640
|
+
!published.isFile() ||
|
|
2641
|
+
published.isSymbolicLink() ||
|
|
2642
|
+
Number(published.nlink) !== 1 ||
|
|
2643
|
+
identity(published) !== identity(staged) ||
|
|
2644
|
+
!samePath(realpath(this.#fs, publishedPath), publishedPath)
|
|
2645
|
+
) {
|
|
2646
|
+
throw failure(
|
|
2647
|
+
"SKILL_RELEASE_STORE_UNSAFE",
|
|
2648
|
+
"active state pointer changed during publication",
|
|
2649
|
+
);
|
|
2650
|
+
}
|
|
2651
|
+
const publishedBytes = this.#readBytes(
|
|
2652
|
+
publishedPath,
|
|
2653
|
+
"SKILL_RELEASE_STATE_CORRUPT",
|
|
2654
|
+
);
|
|
2655
|
+
let verifiedPublished;
|
|
2656
|
+
try {
|
|
2657
|
+
verifiedPublished = verifyState(
|
|
2658
|
+
this.#parseCanonical(publishedBytes, "SKILL_RELEASE_STATE_CORRUPT"),
|
|
2659
|
+
);
|
|
2660
|
+
} catch (cause) {
|
|
2661
|
+
if (cause instanceof SkillReleaseRegistryError) throw cause;
|
|
2662
|
+
throw failure(
|
|
2663
|
+
"SKILL_RELEASE_STATE_CORRUPT",
|
|
2664
|
+
"published state pointer failed verification",
|
|
2665
|
+
{ cause },
|
|
2666
|
+
);
|
|
2667
|
+
}
|
|
2668
|
+
if (
|
|
2669
|
+
verifiedPublished.tenantId !== this.tenantId ||
|
|
2670
|
+
verifiedPublished.stateDigest !== state.stateDigest ||
|
|
2671
|
+
!publishedBytes.equals(expectedBytes)
|
|
2672
|
+
) {
|
|
2673
|
+
throw failure(
|
|
2674
|
+
"SKILL_RELEASE_STATE_CORRUPT",
|
|
2675
|
+
"published state pointer does not exactly match the journal next state",
|
|
2676
|
+
);
|
|
2677
|
+
}
|
|
2678
|
+
fsyncDirectory(this.#fs, this.#directories.active.path);
|
|
2679
|
+
this.#assertBoundary();
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
#assertFinalizationArtifacts(state, release) {
|
|
2683
|
+
try {
|
|
2684
|
+
const expectedStateBytes = serialize(state);
|
|
2685
|
+
const stateBytes = this.#readBytes(
|
|
2686
|
+
this.#statePath(state.skillName),
|
|
2687
|
+
"SKILL_RELEASE_STATE_CORRUPT",
|
|
2688
|
+
);
|
|
2689
|
+
const verifiedState = verifyState(
|
|
2690
|
+
this.#parseCanonical(stateBytes, "SKILL_RELEASE_STATE_CORRUPT"),
|
|
2691
|
+
);
|
|
2692
|
+
if (
|
|
2693
|
+
verifiedState.tenantId !== this.tenantId ||
|
|
2694
|
+
verifiedState.skillName !== state.skillName ||
|
|
2695
|
+
verifiedState.transactionId !== state.transactionId ||
|
|
2696
|
+
verifiedState.fence !== state.fence ||
|
|
2697
|
+
verifiedState.stateDigest !== state.stateDigest ||
|
|
2698
|
+
!stateBytes.equals(expectedStateBytes)
|
|
2699
|
+
) {
|
|
2700
|
+
throw failure(
|
|
2701
|
+
"SKILL_RELEASE_STATE_CORRUPT",
|
|
2702
|
+
"active state pointer is not the exact finalization input",
|
|
2703
|
+
);
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
const verifiedRelease = this.readRelease(release.releaseDigest);
|
|
2707
|
+
if (
|
|
2708
|
+
verifiedRelease.tenantId !== this.tenantId ||
|
|
2709
|
+
verifiedRelease.skillName !== state.skillName ||
|
|
2710
|
+
verifiedRelease.releaseDigest !== release.releaseDigest ||
|
|
2711
|
+
!serialize(verifiedRelease).equals(serialize(release))
|
|
2712
|
+
) {
|
|
2713
|
+
throw failure(
|
|
2714
|
+
"SKILL_RELEASE_CORRUPT",
|
|
2715
|
+
"target release is not the exact finalization input",
|
|
2716
|
+
);
|
|
2717
|
+
}
|
|
2718
|
+
} catch (cause) {
|
|
2719
|
+
throw failure(
|
|
2720
|
+
"SKILL_RELEASE_FINALIZATION_INPUT_INVALID",
|
|
2721
|
+
"finalization inputs changed after pointer publication",
|
|
2722
|
+
{
|
|
2723
|
+
cause,
|
|
2724
|
+
preserveForRecovery: true,
|
|
2725
|
+
transactionId: state.transactionId,
|
|
2726
|
+
},
|
|
2727
|
+
);
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
#assertJournalFinalizationArtifacts(journal) {
|
|
2732
|
+
let release;
|
|
2733
|
+
try {
|
|
2734
|
+
release = this.readRelease(journal.nextState.activeReleaseDigest);
|
|
2735
|
+
} catch (cause) {
|
|
2736
|
+
throw failure(
|
|
2737
|
+
"SKILL_RELEASE_FINALIZATION_INPUT_INVALID",
|
|
2738
|
+
"journal target release is not a valid recovery finalization input",
|
|
2739
|
+
{
|
|
2740
|
+
cause,
|
|
2741
|
+
preserveForRecovery: true,
|
|
2742
|
+
transactionId: journal.transactionId,
|
|
2743
|
+
},
|
|
2744
|
+
);
|
|
2745
|
+
}
|
|
2746
|
+
this.#assertFinalizationArtifacts(journal.nextState, release);
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
#restorePrevious(journal) {
|
|
2750
|
+
const current = this.#readStateRaw(journal.skillName);
|
|
2751
|
+
const previousDigest = journal.previousState?.stateDigest ?? null;
|
|
2752
|
+
if (current.revision === 0 && previousDigest === null) return;
|
|
2753
|
+
if (current.stateDigest !== previousDigest) {
|
|
2754
|
+
throw failure(
|
|
2755
|
+
"SKILL_RELEASE_RECOVERY_CONFLICT",
|
|
2756
|
+
"an absent transaction cannot authorize pointer replacement",
|
|
2757
|
+
);
|
|
2758
|
+
}
|
|
2759
|
+
this.#verifyCommittedState(current);
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
#ensureNextPointer(journal) {
|
|
2763
|
+
const current = this.#readStateRaw(journal.skillName);
|
|
2764
|
+
const previousDigest = journal.previousState?.stateDigest ?? null;
|
|
2765
|
+
if (current.stateDigest === journal.nextState.stateDigest) return;
|
|
2766
|
+
if (
|
|
2767
|
+
(current.revision === 0 && previousDigest === null) ||
|
|
2768
|
+
current.stateDigest === previousDigest
|
|
2769
|
+
) {
|
|
2770
|
+
this.#atomicWrite(
|
|
2771
|
+
"active",
|
|
2772
|
+
this.#statePath(journal.skillName),
|
|
2773
|
+
journal.nextState,
|
|
2774
|
+
);
|
|
2775
|
+
return;
|
|
2776
|
+
}
|
|
2777
|
+
throw failure(
|
|
2778
|
+
"SKILL_RELEASE_RECOVERY_CONFLICT",
|
|
2779
|
+
"recovery CAS found an unrelated active revision",
|
|
2780
|
+
);
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
#cleanupTransaction(journal, { removeLock = true } = {}) {
|
|
2784
|
+
if (removeLock) {
|
|
2785
|
+
const lockPath = this.#leasePath(journal.skillName);
|
|
2786
|
+
try {
|
|
2787
|
+
this.#removeLock(lockPath, journal.leaseToken);
|
|
2788
|
+
} catch (error) {
|
|
2789
|
+
if (error?.code !== "ENOENT") throw error;
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
this.#unlink(
|
|
2793
|
+
this.#path("staging", journal.stagedFile),
|
|
2794
|
+
this.#directories.staging.path,
|
|
2795
|
+
);
|
|
2796
|
+
this.#unlink(
|
|
2797
|
+
this.#journalPath(journal.skillName),
|
|
2798
|
+
this.#directories.journals.path,
|
|
2799
|
+
);
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
#recoverJournal(journal) {
|
|
2803
|
+
const expected = {
|
|
2804
|
+
authorityReceiptDigest: journal.intent.authorityReceiptDigest,
|
|
2805
|
+
intentDigest: journal.intent.intentDigest,
|
|
2806
|
+
pointerDigest: journal.nextState.stateDigest,
|
|
2807
|
+
prepareReceiptDigest: journal.prepareReceipt?.receiptDigest,
|
|
2808
|
+
revision: journal.nextState.revision,
|
|
2809
|
+
skillName: journal.nextState.skillName,
|
|
2810
|
+
stateDigest: journal.nextState.stateDigest,
|
|
2811
|
+
};
|
|
2812
|
+
const projection = this.#query(journal.transactionId, expected);
|
|
2813
|
+
if (projection.status === "absent") {
|
|
2814
|
+
this.#restorePrevious(journal);
|
|
2815
|
+
this.#cleanupTransaction(journal);
|
|
2816
|
+
return "aborted";
|
|
2817
|
+
}
|
|
2818
|
+
if (projection.status === "prepared") {
|
|
2819
|
+
const prepareReceipt = verifyLedgerProjection(projection, {
|
|
2820
|
+
authorityReceiptDigest: journal.intent.authorityReceiptDigest,
|
|
2821
|
+
intentDigest: journal.intent.intentDigest,
|
|
2822
|
+
transactionId: journal.transactionId,
|
|
2823
|
+
});
|
|
2824
|
+
this.#ensureNextPointer(journal);
|
|
2825
|
+
this.#assertJournalFinalizationArtifacts(journal);
|
|
2826
|
+
this.#finalize(journal, prepareReceipt);
|
|
2827
|
+
this.#assertJournalFinalizationArtifacts(journal);
|
|
2828
|
+
this.#cleanupTransaction(journal);
|
|
2829
|
+
return "committed";
|
|
2830
|
+
}
|
|
2831
|
+
if (projection.current !== true) {
|
|
2832
|
+
throw failure(
|
|
2833
|
+
"SKILL_RELEASE_RECOVERY_CONFLICT",
|
|
2834
|
+
"committed transaction is no longer the current Skill revision",
|
|
2835
|
+
);
|
|
2836
|
+
}
|
|
2837
|
+
this.#ensureNextPointer(journal);
|
|
2838
|
+
this.#assertJournalFinalizationArtifacts(journal);
|
|
2839
|
+
this.#cleanupTransaction(journal);
|
|
2840
|
+
return "committed";
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
#recoverSkill(name, { force = false } = {}) {
|
|
2844
|
+
const journalPath = this.#journalPath(name);
|
|
2845
|
+
const lockPath = this.#leasePath(name);
|
|
2846
|
+
if (!this.#exists(journalPath)) {
|
|
2847
|
+
if (this.#exists(lockPath)) {
|
|
2848
|
+
const owner = this.#readOwner(lockPath);
|
|
2849
|
+
if (force || this.#ownerStale(owner)) {
|
|
2850
|
+
this.#removeLock(lockPath, owner.token);
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2853
|
+
return;
|
|
2854
|
+
}
|
|
2855
|
+
const journal = this.#readJournal(name);
|
|
2856
|
+
if (this.#exists(lockPath)) {
|
|
2857
|
+
const owner = this.#readOwner(lockPath);
|
|
2858
|
+
if (
|
|
2859
|
+
owner.token !== journal.leaseToken ||
|
|
2860
|
+
owner.transactionId !== journal.transactionId
|
|
2861
|
+
) {
|
|
2862
|
+
throw failure(
|
|
2863
|
+
"SKILL_RELEASE_RECOVERY_CONFLICT",
|
|
2864
|
+
"journal and lease owner differ",
|
|
2865
|
+
);
|
|
2866
|
+
}
|
|
2867
|
+
if (!force && !this.#ownerStale(owner)) return;
|
|
2868
|
+
}
|
|
2869
|
+
this.#recoverJournal(journal);
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
#recoverAll() {
|
|
2873
|
+
this.#withRecoveryLock(() => {
|
|
2874
|
+
const names = this.#fs.readdirSync(this.#directories.journals.path);
|
|
2875
|
+
for (const name of names) {
|
|
2876
|
+
if (!name.endsWith(".json")) {
|
|
2877
|
+
throw failure(
|
|
2878
|
+
"SKILL_RELEASE_JOURNAL_CORRUPT",
|
|
2879
|
+
"unexpected journal entry",
|
|
2880
|
+
);
|
|
2881
|
+
}
|
|
2882
|
+
const nameWithoutExtension = name.slice(0, -5);
|
|
2883
|
+
skillName(nameWithoutExtension);
|
|
2884
|
+
this.#recoverSkill(nameWithoutExtension);
|
|
2885
|
+
}
|
|
2886
|
+
for (const entry of this.#fs.readdirSync(this.#directories.locks.path)) {
|
|
2887
|
+
if (entry === ".recovery.lock" || entry.startsWith(".released-"))
|
|
2888
|
+
continue;
|
|
2889
|
+
if (!entry.endsWith(".lock")) {
|
|
2890
|
+
throw failure("SKILL_RELEASE_STORE_UNSAFE", "unexpected lock entry");
|
|
2891
|
+
}
|
|
2892
|
+
const name = entry.slice(0, -5);
|
|
2893
|
+
skillName(name);
|
|
2894
|
+
if (!this.#exists(this.#journalPath(name))) this.#recoverSkill(name);
|
|
2895
|
+
}
|
|
2896
|
+
});
|
|
2897
|
+
}
|
|
2898
|
+
|
|
2899
|
+
#cleanupDebris() {
|
|
2900
|
+
const referenced = new Set();
|
|
2901
|
+
for (const name of this.#fs.readdirSync(this.#directories.journals.path)) {
|
|
2902
|
+
if (name.endsWith(".json")) {
|
|
2903
|
+
referenced.add(this.#readJournal(name.slice(0, -5)).stagedFile);
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
const cutoff = this.#clock().getTime() - this.#leaseTtlMs;
|
|
2907
|
+
for (const name of this.#fs.readdirSync(this.#directories.staging.path)) {
|
|
2908
|
+
if (!TEMP_PATTERN.test(name)) {
|
|
2909
|
+
throw failure("SKILL_RELEASE_STORE_UNSAFE", "unexpected staging entry");
|
|
2910
|
+
}
|
|
2911
|
+
const filePath = this.#path("staging", name);
|
|
2912
|
+
const stat = this.#fs.lstatSync(filePath);
|
|
2913
|
+
if (
|
|
2914
|
+
!stat.isFile() ||
|
|
2915
|
+
stat.isSymbolicLink() ||
|
|
2916
|
+
Number(stat.nlink) !== 1 ||
|
|
2917
|
+
!samePath(realpath(this.#fs, filePath), filePath)
|
|
2918
|
+
) {
|
|
2919
|
+
throw failure("SKILL_RELEASE_STORE_UNSAFE", "staging debris is unsafe");
|
|
2920
|
+
}
|
|
2921
|
+
if (!referenced.has(name) && stat.mtimeMs <= cutoff) {
|
|
2922
|
+
this.#unlink(filePath, this.#directories.staging.path);
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
for (const name of this.#fs.readdirSync(this.#directories.locks.path)) {
|
|
2926
|
+
if (!name.startsWith(".released-")) continue;
|
|
2927
|
+
const tombstone = this.#path("locks", name);
|
|
2928
|
+
const stat = this.#fs.lstatSync(tombstone);
|
|
2929
|
+
if (
|
|
2930
|
+
!stat.isDirectory() ||
|
|
2931
|
+
stat.isSymbolicLink() ||
|
|
2932
|
+
!samePath(realpath(this.#fs, tombstone), tombstone) ||
|
|
2933
|
+
!samePath(path.dirname(tombstone), this.#directories.locks.path)
|
|
2934
|
+
) {
|
|
2935
|
+
throw failure("SKILL_RELEASE_STORE_UNSAFE", "lock tombstone is unsafe");
|
|
2936
|
+
}
|
|
2937
|
+
if (stat.mtimeMs <= cutoff)
|
|
2938
|
+
this.#fs.rmSync(tombstone, { recursive: true });
|
|
2939
|
+
}
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2942
|
+
#validateTransitionPayload(value) {
|
|
2943
|
+
const keys = new Set([
|
|
2944
|
+
"authorityReceipt",
|
|
2945
|
+
"candidate",
|
|
2946
|
+
"dependencyLockDigest",
|
|
2947
|
+
"expectedParentDigest",
|
|
2948
|
+
"expectedRevision",
|
|
2949
|
+
"mutationRequest",
|
|
2950
|
+
"operation",
|
|
2951
|
+
"operationId",
|
|
2952
|
+
"receiptDigests",
|
|
2953
|
+
"requestDigest",
|
|
2954
|
+
"skillName",
|
|
2955
|
+
"targetReleaseDigest",
|
|
2956
|
+
"tenantId",
|
|
2957
|
+
"transitionSubjectDigest",
|
|
2958
|
+
]);
|
|
2959
|
+
assertExactKeys(value, keys, "transition capability payload");
|
|
2960
|
+
const authorityReceipt = verifySkillMutationConsumptionReceipt(
|
|
2961
|
+
value.authorityReceipt,
|
|
2962
|
+
);
|
|
2963
|
+
const mutationRequest = verifySkillMutationRequest(value.mutationRequest);
|
|
2964
|
+
if (
|
|
2965
|
+
authorityReceipt.role !== SKILL_MUTATION_ROLES.PROMOTION_CONTROLLER ||
|
|
2966
|
+
authorityReceipt.tenantId !== this.tenantId ||
|
|
2967
|
+
authorityReceipt.targetScope !== SKILL_MUTATION_TARGET_SCOPES.ACTIVE ||
|
|
2968
|
+
authorityReceipt.skillName !== value.skillName ||
|
|
2969
|
+
authorityReceipt.operationId !== value.operationId ||
|
|
2970
|
+
authorityReceipt.operation !== value.operation ||
|
|
2971
|
+
authorityReceipt.transitionSubjectDigest !==
|
|
2972
|
+
value.transitionSubjectDigest ||
|
|
2973
|
+
authorityReceipt.requestDigest !== value.requestDigest ||
|
|
2974
|
+
authorityReceipt.expectedTargetDigest !== value.expectedParentDigest ||
|
|
2975
|
+
authorityReceipt.expectedTargetRevision !== value.expectedRevision
|
|
2976
|
+
) {
|
|
2977
|
+
throw failure(
|
|
2978
|
+
"SKILL_RELEASE_AUTHORITY_INVALID",
|
|
2979
|
+
"transition capability authority receipt bindings differ",
|
|
2980
|
+
);
|
|
2981
|
+
}
|
|
2982
|
+
if (
|
|
2983
|
+
![
|
|
2984
|
+
SKILL_MUTATION_OPERATIONS.PROMOTE,
|
|
2985
|
+
SKILL_MUTATION_OPERATIONS.ROLLBACK,
|
|
2986
|
+
].includes(value.operation)
|
|
2987
|
+
) {
|
|
2988
|
+
throw failure("SKILL_RELEASE_INVALID", "transition operation is invalid");
|
|
2989
|
+
}
|
|
2990
|
+
verifyRequestReceiptBinding(mutationRequest, authorityReceipt);
|
|
2991
|
+
if (
|
|
2992
|
+
mutationRequest.requestDigest !== value.requestDigest ||
|
|
2993
|
+
mutationRequest.tenantId !== this.tenantId ||
|
|
2994
|
+
tenantId(value.tenantId, "transition tenantId") !== this.tenantId ||
|
|
2995
|
+
mutationRequest.operation !== value.operation ||
|
|
2996
|
+
mutationRequest.transitionSubjectDigest !==
|
|
2997
|
+
value.transitionSubjectDigest ||
|
|
2998
|
+
canonicalJson(receiptDigestsFromRequest(mutationRequest)) !==
|
|
2999
|
+
canonicalJson(value.receiptDigests)
|
|
3000
|
+
) {
|
|
3001
|
+
throw failure(
|
|
3002
|
+
"SKILL_RELEASE_AUTHORITY_INVALID",
|
|
3003
|
+
"transition receipts differ from the consumed mutation request",
|
|
3004
|
+
);
|
|
3005
|
+
}
|
|
3006
|
+
let candidate = null;
|
|
3007
|
+
let targetReleaseDigest = null;
|
|
3008
|
+
const dependencyLockDigest = digest(
|
|
3009
|
+
value.dependencyLockDigest,
|
|
3010
|
+
"dependencyLockDigest",
|
|
3011
|
+
);
|
|
3012
|
+
if (value.operation === "promote") {
|
|
3013
|
+
candidate = verifySkillCandidateDraft(value.candidate);
|
|
3014
|
+
if (
|
|
3015
|
+
candidate.tenantId !== this.tenantId ||
|
|
3016
|
+
candidate.skillName !== value.skillName ||
|
|
3017
|
+
value.targetReleaseDigest !== null
|
|
3018
|
+
) {
|
|
3019
|
+
throw failure(
|
|
3020
|
+
"SKILL_RELEASE_INVALID",
|
|
3021
|
+
"promotion requires a tenant-bound candidate and no caller target digest",
|
|
3022
|
+
);
|
|
3023
|
+
}
|
|
3024
|
+
if (candidate.dependencyLockDigest !== dependencyLockDigest) {
|
|
3025
|
+
throw failure(
|
|
3026
|
+
"SKILL_RELEASE_AUTHORITY_INVALID",
|
|
3027
|
+
"candidate dependency lock differs from its authorized digest",
|
|
3028
|
+
);
|
|
3029
|
+
}
|
|
3030
|
+
} else {
|
|
3031
|
+
if (value.candidate !== null) {
|
|
3032
|
+
throw failure(
|
|
3033
|
+
"SKILL_RELEASE_INVALID",
|
|
3034
|
+
"rollback must not carry candidate release material",
|
|
3035
|
+
);
|
|
3036
|
+
}
|
|
3037
|
+
targetReleaseDigest = digest(
|
|
3038
|
+
value.targetReleaseDigest,
|
|
3039
|
+
"targetReleaseDigest",
|
|
3040
|
+
);
|
|
3041
|
+
}
|
|
3042
|
+
const expectedTransitionSubjectDigest =
|
|
3043
|
+
digestSkillMutationTransitionSubject({
|
|
3044
|
+
tenantId: mutationRequest.tenantId,
|
|
3045
|
+
skillName: value.skillName,
|
|
3046
|
+
operation: value.operation,
|
|
3047
|
+
candidateId: candidate?.candidateId ?? null,
|
|
3048
|
+
rollbackTargetReleaseDigest: targetReleaseDigest,
|
|
3049
|
+
dependencyLockDigest,
|
|
3050
|
+
expectedActiveContentDigest: value.expectedParentDigest,
|
|
3051
|
+
expectedActiveRevision: value.expectedRevision,
|
|
3052
|
+
});
|
|
3053
|
+
if (value.transitionSubjectDigest !== expectedTransitionSubjectDigest) {
|
|
3054
|
+
throw failure(
|
|
3055
|
+
"SKILL_RELEASE_AUTHORITY_INVALID",
|
|
3056
|
+
"transition capability subject differs from the exact registry mutation",
|
|
3057
|
+
);
|
|
3058
|
+
}
|
|
3059
|
+
return deepFreeze({
|
|
3060
|
+
authorityReceipt,
|
|
3061
|
+
candidate,
|
|
3062
|
+
dependencyLockDigest,
|
|
3063
|
+
expectedParentDigest: digest(
|
|
3064
|
+
value.expectedParentDigest,
|
|
3065
|
+
"expectedParentDigest",
|
|
3066
|
+
),
|
|
3067
|
+
expectedRevision: safeInteger(value.expectedRevision, "expectedRevision"),
|
|
3068
|
+
mutationRequest,
|
|
3069
|
+
operation: value.operation,
|
|
3070
|
+
operationId: boundedString(value.operationId, "operationId"),
|
|
3071
|
+
receiptDigests: normalizeReceiptDigests(value.receiptDigests),
|
|
3072
|
+
requestDigest: digest(value.requestDigest, "requestDigest"),
|
|
3073
|
+
skillName: skillName(value.skillName),
|
|
3074
|
+
targetReleaseDigest,
|
|
3075
|
+
tenantId: this.tenantId,
|
|
3076
|
+
transitionSubjectDigest: digest(
|
|
3077
|
+
value.transitionSubjectDigest,
|
|
3078
|
+
"transitionSubjectDigest",
|
|
3079
|
+
),
|
|
3080
|
+
});
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3083
|
+
async #crash(phase, transaction) {
|
|
3084
|
+
if (this.#crashHook) await this.#crashHook(phase, deepFreeze(transaction));
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
async applyTransition(capability) {
|
|
3088
|
+
const payload = this.#validateTransitionPayload(
|
|
3089
|
+
consumeRegistryTransitionCapability(capability, this),
|
|
3090
|
+
);
|
|
3091
|
+
let releaseCreated = false;
|
|
3092
|
+
let target;
|
|
3093
|
+
if (payload.operation === "promote") {
|
|
3094
|
+
const created = this.#createRelease({
|
|
3095
|
+
candidate: payload.candidate,
|
|
3096
|
+
consumptionReceipt: payload.authorityReceipt,
|
|
3097
|
+
mutationRequest: payload.mutationRequest,
|
|
3098
|
+
});
|
|
3099
|
+
target = created.release;
|
|
3100
|
+
releaseCreated = created.created;
|
|
3101
|
+
} else {
|
|
3102
|
+
target = this.readRelease(payload.targetReleaseDigest);
|
|
3103
|
+
}
|
|
3104
|
+
if (
|
|
3105
|
+
target.skillName !== payload.skillName ||
|
|
3106
|
+
target.tenantId !== this.tenantId
|
|
3107
|
+
) {
|
|
3108
|
+
throw failure("SKILL_RELEASE_INVALID", "target belongs to another Skill");
|
|
3109
|
+
}
|
|
3110
|
+
if (target.dependencyLockDigest !== payload.dependencyLockDigest) {
|
|
3111
|
+
throw failure(
|
|
3112
|
+
"SKILL_RELEASE_AUTHORITY_INVALID",
|
|
3113
|
+
"target release dependency lock differs from the authorized transition subject",
|
|
3114
|
+
);
|
|
3115
|
+
}
|
|
3116
|
+
if (
|
|
3117
|
+
payload.operation === "promote" &&
|
|
3118
|
+
(target.authorityReceiptDigest !==
|
|
3119
|
+
payload.authorityReceipt.receiptDigest ||
|
|
3120
|
+
target.mutationRequestDigest !== payload.requestDigest ||
|
|
3121
|
+
target.transitionSubjectDigest !== payload.transitionSubjectDigest ||
|
|
3122
|
+
canonicalJson(target.receiptDigests) !==
|
|
3123
|
+
canonicalJson(payload.receiptDigests))
|
|
3124
|
+
) {
|
|
3125
|
+
throw failure(
|
|
3126
|
+
"SKILL_RELEASE_AUTHORITY_INVALID",
|
|
3127
|
+
"release does not carry the consumed authorization evidence",
|
|
3128
|
+
);
|
|
3129
|
+
}
|
|
3130
|
+
|
|
3131
|
+
const transactionId = domainDigest("chainlesschain.skill-release-tx/v2\0", {
|
|
3132
|
+
authorityReceiptDigest: payload.authorityReceipt.receiptDigest,
|
|
3133
|
+
operation: payload.operation,
|
|
3134
|
+
operationId: payload.operationId,
|
|
3135
|
+
requestDigest: payload.requestDigest,
|
|
3136
|
+
targetReleaseDigest: target.releaseDigest,
|
|
3137
|
+
tenantId: this.tenantId,
|
|
3138
|
+
transitionSubjectDigest: payload.transitionSubjectDigest,
|
|
3139
|
+
});
|
|
3140
|
+
const acquired = this.#acquireLease(
|
|
3141
|
+
payload.skillName,
|
|
3142
|
+
transactionId,
|
|
3143
|
+
payload.expectedRevision,
|
|
3144
|
+
payload.expectedParentDigest,
|
|
3145
|
+
);
|
|
3146
|
+
const { lease, state: previousState, heartbeat } = acquired;
|
|
3147
|
+
let journal = null;
|
|
3148
|
+
let prepareReceipt = null;
|
|
3149
|
+
let finalized = null;
|
|
3150
|
+
try {
|
|
3151
|
+
await this.#crash("after-lease", {
|
|
3152
|
+
fence: lease.fence,
|
|
3153
|
+
skillName: payload.skillName,
|
|
3154
|
+
tenantId: this.tenantId,
|
|
3155
|
+
transactionId,
|
|
3156
|
+
});
|
|
3157
|
+
if (
|
|
3158
|
+
payload.operation === "rollback" &&
|
|
3159
|
+
previousState.lastKnownGoodReleaseDigest !== target.releaseDigest
|
|
3160
|
+
) {
|
|
3161
|
+
throw failure(
|
|
3162
|
+
"SKILL_RELEASE_ROLLBACK_INVALID",
|
|
3163
|
+
"rollback target is no longer last-known-good",
|
|
3164
|
+
);
|
|
3165
|
+
}
|
|
3166
|
+
const nextState = buildState({
|
|
3167
|
+
activeReleaseDigest: target.releaseDigest,
|
|
3168
|
+
authorityReceiptDigest: payload.authorityReceipt.receiptDigest,
|
|
3169
|
+
dependencyLockDigest: target.dependencyLockDigest,
|
|
3170
|
+
fence: lease.fence,
|
|
3171
|
+
lastKnownGoodReleaseDigest:
|
|
3172
|
+
payload.operation === "promote"
|
|
3173
|
+
? (previousState.activeReleaseDigest ?? target.releaseDigest)
|
|
3174
|
+
: target.releaseDigest,
|
|
3175
|
+
revision: previousState.revision + 1,
|
|
3176
|
+
skillName: payload.skillName,
|
|
3177
|
+
tenantId: this.tenantId,
|
|
3178
|
+
transactionId,
|
|
3179
|
+
});
|
|
3180
|
+
const intent = this.#buildIntent(payload, previousState, nextState);
|
|
3181
|
+
journal = this.#persistJournal({
|
|
3182
|
+
intent,
|
|
3183
|
+
leaseToken: lease.token,
|
|
3184
|
+
nextState,
|
|
3185
|
+
phase: "journaled",
|
|
3186
|
+
prepareReceipt: null,
|
|
3187
|
+
previousState: previousState.revision === 0 ? null : previousState,
|
|
3188
|
+
schema: JOURNAL_SCHEMA,
|
|
3189
|
+
skillName: payload.skillName,
|
|
3190
|
+
stagedFile: `.state-${transactionId.slice(7)}.tmp`,
|
|
3191
|
+
tenantId: this.tenantId,
|
|
3192
|
+
transactionId,
|
|
3193
|
+
});
|
|
3194
|
+
await this.#crash("after-journal", journal);
|
|
3195
|
+
|
|
3196
|
+
this.#renewLease(lease);
|
|
3197
|
+
prepareReceipt = this.#prepare(intent);
|
|
3198
|
+
journal = this.#persistJournal({
|
|
3199
|
+
...journal,
|
|
3200
|
+
phase: "prepared",
|
|
3201
|
+
prepareReceipt,
|
|
3202
|
+
});
|
|
3203
|
+
await this.#crash("after-prepare", journal);
|
|
3204
|
+
|
|
3205
|
+
this.#renewLease(lease);
|
|
3206
|
+
this.#writeTemporary(journal.stagedFile, nextState);
|
|
3207
|
+
await this.#crash("after-staging-fsync", journal);
|
|
3208
|
+
this.#writePointer(nextState, journal.stagedFile);
|
|
3209
|
+
journal = this.#persistJournal({ ...journal, phase: "pointer-written" });
|
|
3210
|
+
await this.#crash("after-pointer", journal);
|
|
3211
|
+
|
|
3212
|
+
this.#renewLease(lease);
|
|
3213
|
+
this.#assertFinalizationArtifacts(nextState, target);
|
|
3214
|
+
finalized = this.#finalize(journal, prepareReceipt);
|
|
3215
|
+
this.#assertFinalizationArtifacts(nextState, target);
|
|
3216
|
+
await this.#crash("after-finalize", { journal, finalized });
|
|
3217
|
+
this.#assertFinalizationArtifacts(nextState, target);
|
|
3218
|
+
|
|
3219
|
+
clearInterval(heartbeat);
|
|
3220
|
+
this.#cleanupTransaction(journal);
|
|
3221
|
+
const receiptCore = {
|
|
3222
|
+
activeReleaseDigest: nextState.activeReleaseDigest,
|
|
3223
|
+
authorityReceiptDigest: payload.authorityReceipt.receiptDigest,
|
|
3224
|
+
dependencyLockDigest: nextState.dependencyLockDigest,
|
|
3225
|
+
fence: nextState.fence,
|
|
3226
|
+
finalizeReceiptDigest: finalized.receiptDigest,
|
|
3227
|
+
fromReleaseDigest: previousState.activeReleaseDigest,
|
|
3228
|
+
intentDigest: intent.intentDigest,
|
|
3229
|
+
operation: payload.operation,
|
|
3230
|
+
prepareReceiptDigest: prepareReceipt.receiptDigest,
|
|
3231
|
+
receiptDigests: payload.receiptDigests,
|
|
3232
|
+
requestDigest: payload.requestDigest,
|
|
3233
|
+
revision: nextState.revision,
|
|
3234
|
+
schema: SKILL_RELEASE_RECEIPT_SCHEMA,
|
|
3235
|
+
skillName: payload.skillName,
|
|
3236
|
+
stateDigest: nextState.stateDigest,
|
|
3237
|
+
tenantId: this.tenantId,
|
|
3238
|
+
transactionId,
|
|
3239
|
+
transitionSubjectDigest: payload.transitionSubjectDigest,
|
|
3240
|
+
};
|
|
3241
|
+
return deepFreeze({
|
|
3242
|
+
release: target,
|
|
3243
|
+
releaseCreated,
|
|
3244
|
+
state: nextState,
|
|
3245
|
+
receipt: {
|
|
3246
|
+
...receiptCore,
|
|
3247
|
+
receiptDigest: domainDigest(RECEIPT_DOMAIN, receiptCore),
|
|
3248
|
+
},
|
|
3249
|
+
});
|
|
3250
|
+
} catch (error) {
|
|
3251
|
+
clearInterval(heartbeat);
|
|
3252
|
+
if (error?.preserveForRecovery === true) throw error;
|
|
3253
|
+
if (journal) {
|
|
3254
|
+
try {
|
|
3255
|
+
this.#withRecoveryLock(() =>
|
|
3256
|
+
this.#recoverJournal(this.#readJournal(payload.skillName)),
|
|
3257
|
+
);
|
|
3258
|
+
} catch (recoveryError) {
|
|
3259
|
+
throw failure(
|
|
3260
|
+
"SKILL_RELEASE_COMMIT_UNKNOWN",
|
|
3261
|
+
"transition failed and authenticated recovery did not converge",
|
|
3262
|
+
{ cause: recoveryError, transactionId },
|
|
3263
|
+
);
|
|
3264
|
+
}
|
|
3265
|
+
} else {
|
|
3266
|
+
this.#releaseLease(lease);
|
|
3267
|
+
}
|
|
3268
|
+
if (error instanceof SkillReleaseRegistryError) throw error;
|
|
3269
|
+
throw failure("SKILL_RELEASE_WRITE_FAILED", "transition failed", {
|
|
3270
|
+
cause: error,
|
|
3271
|
+
transactionId,
|
|
3272
|
+
});
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
}
|
|
3276
|
+
|
|
3277
|
+
Object.freeze(SkillReleaseRegistry.prototype);
|