@sema-agent/settings-schema 10.1.1 → 11.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/CHANGELOG.md +469 -1
  2. package/README.md +144 -66
  3. package/bin/settings-schema.mjs +10 -12
  4. package/conformance/VERSION +1 -1
  5. package/conformance/baseline.json +3 -1
  6. package/conformance/cases/c01-entry-salvage-then-typo/expected.json +3 -1
  7. package/conformance/cases/c02-hosts-clamp-stacked/expected.json +12 -5
  8. package/conformance/cases/c02-hosts-clamp-stacked/meta.json +1 -1
  9. package/conformance/cases/c03-literal-dotted-top-key/expected.json +22 -2
  10. package/conformance/cases/c04-all-bad-collection-not-salvaged/expected.json +3 -1
  11. package/conformance/cases/c06-legacy-governance-lift/expected.json +11 -101
  12. package/conformance/cases/c06-legacy-governance-lift/meta.json +1 -1
  13. package/conformance/cases/c07-hooks-is-not-a-config-domain/expected.json +3 -1
  14. package/conformance/cases/c08-limits-unknown-carried-64/expected.json +203 -2
  15. package/conformance/cases/c09-limits-unknown-carried-65/expected.json +204 -2
  16. package/conformance/cases/c10-record-key-secret-redacted-twice/expected.json +3 -1
  17. package/conformance/cases/c11-unicode-unknown-keys/expected.json +20 -2
  18. package/conformance/cases/c12-prototype-name-top-keys/expected.json +3 -1
  19. package/conformance/cases/c13-models-field-null/expected.json +3 -1
  20. package/conformance/cases/c14-models-field-empty-array/expected.json +6 -1
  21. package/conformance/cases/c15-models-field-number/expected.json +3 -1
  22. package/conformance/cases/c16-deep-nesting-205/expected.json +14 -2
  23. package/conformance/cases/c17-empty-domain-file/expected.json +3 -1
  24. package/conformance/cases/c18-wire-envelope-not-an-object/expected.json +3 -1
  25. package/conformance/cases/c19-wire-envelope-no-version/expected.json +3 -1
  26. package/conformance/cases/c20-governance-absent-lifts/expected.json +11 -86
  27. package/conformance/cases/c20-governance-absent-lifts/meta.json +1 -1
  28. package/conformance/cases/c21-governance-empty-object-wins/expected.json +6 -5
  29. package/conformance/cases/c21-governance-empty-object-wins/input.json +1 -4
  30. package/conformance/cases/c21-governance-empty-object-wins/meta.json +1 -1
  31. package/conformance/cases/c23-mixed-validity-models/expected.json +3 -1
  32. package/conformance/cases/c24-valid-entries-with-unknown-keys/expected.json +22 -2
  33. package/conformance/cases/c25-published-roundtrip-zero-warnings/expected.json +6 -1
  34. package/conformance/cases/c26-hosts-array-1024/expected.json +3 -1
  35. package/conformance/cases/c27-hosts-array-1025/expected.json +6 -7175
  36. package/conformance/cases/c27-hosts-array-1025/meta.json +1 -1
  37. package/conformance/cases/c28-large-document-over-1mb/expected.json +14 -2
  38. package/conformance/cases/c29-salvage-then-domain-refine-fails/expected.json +3 -1
  39. package/conformance/cases/c30-version-triple-a-first/expected.json +6 -1
  40. package/conformance/cases/c31-version-triple-b-changed/expected.json +6 -1
  41. package/conformance/cases/c32-version-triple-a-restored/expected.json +6 -1
  42. package/conformance/cases/c33-limits-nested-carried-key/expected.json +18 -2
  43. package/conformance/cases/c34-skills-content-vs-manifest/expected.json +19 -3
  44. package/conformance/cases/c35-portable-domain-file-is-null/expected.json +3 -1
  45. package/conformance/cases/c36-skills-entry-without-content/expected.json +6 -2
  46. package/conformance/cases/c37-skills-field-null/expected.json +6 -2
  47. package/conformance/cases/c38-remote-exec-is-a-non-domain-file/expected.json +3 -1
  48. package/conformance/cases/c39-hidden-file-in-config-d/expected.json +3 -1
  49. package/conformance/cases/c40-s712-identity-only-what-reads-out/expected.json +11 -4
  50. package/conformance/cases/c41-s792-encoded-pem-identity/expected.json +9 -3
  51. package/conformance/cases/c42-s792-unknown-key-secret-redacted/expected.json +39 -2
  52. package/conformance/cases/c43-s792-form-and-deep-encoded-pem/expected.json +4 -1
  53. package/conformance/cases/c44-s792-utf8-escape-glued-token/expected.json +3 -1
  54. package/conformance/cases/c45-s792-identity-bounds-and-decode-depth/expected.json +19 -10
  55. package/conformance/cases/c45-s792-identity-bounds-and-decode-depth/meta.json +1 -1
  56. package/conformance/cases/{c46-s811-host-name-url-userinfo-characterization → c46-s811-host-name-url-userinfo-withheld}/expected.json +12 -5
  57. package/conformance/cases/c46-s811-host-name-url-userinfo-withheld/meta.json +9 -0
  58. package/conformance/cases/{c47-s811-roles-key-url-userinfo-characterization → c47-s811-roles-key-url-userinfo-withheld}/expected.json +23 -3
  59. package/conformance/cases/{c47-s811-roles-key-url-userinfo-characterization → c47-s811-roles-key-url-userinfo-withheld}/input.json +6 -0
  60. package/conformance/cases/c47-s811-roles-key-url-userinfo-withheld/meta.json +10 -0
  61. package/conformance/cases/c48-s935-model-headers-transported/expected.json +6 -1
  62. package/conformance/cases/c49-s935-model-headers-secret-entry-dropped/expected.json +3 -1
  63. package/conformance/cases/c50-s784-lane-host-legal/expected.json +3 -1
  64. package/conformance/cases/c51-s784-lane-e2b-legal/expected.json +3 -1
  65. package/conformance/cases/c52-s784-lane-k8s-legal/expected.json +3 -1
  66. package/conformance/cases/c53-s784-lane-ssh-legal/expected.json +3 -1
  67. package/conformance/cases/c54-s784-lane-adb-legal/expected.json +3 -1
  68. package/conformance/cases/c55-s784-lane-local-docker-legal/expected.json +3 -1
  69. package/conformance/cases/c56-s784-lane-device-legal/expected.json +3 -1
  70. package/conformance/cases/c57-s784-g1-enabled-no-lane/expected.json +3 -1
  71. package/conformance/cases/c58-s784-g2-multitenant-host/expected.json +3 -1
  72. package/conformance/cases/c59-s784-g3-k8s-missing-image/expected.json +3 -1
  73. package/conformance/cases/c60-s784-g3-e2b-missing-key/expected.json +3 -1
  74. package/conformance/cases/c61-s784-g3-other-lane-key/expected.json +3 -1
  75. package/conformance/cases/c62-s784-g7a-session-tidb/expected.json +3 -1
  76. package/conformance/cases/c63-s784-host-two-replicas/expected.json +3 -1
  77. package/conformance/cases/c64-s784-multitenant-local-store/expected.json +3 -1
  78. package/conformance/cases/c65-s784-pg-without-db/expected.json +3 -1
  79. package/conformance/cases/c66-s784-memory-backend-mismatch/expected.json +3 -1
  80. package/conformance/cases/c67-s784-retired-field-loud/expected.json +3 -1
  81. package/conformance/cases/c67-s784-retired-field-loud/meta.json +1 -1
  82. package/conformance/cases/c68-s784-device-local-store/expected.json +3 -1
  83. package/conformance/cases/c69-deep-encoded-token-past-every-reading/expected.json +3 -1
  84. package/conformance/cases/c70-rewritten-token-in-front-of-encoded-pem-header/expected.json +3 -1
  85. package/conformance/cases/c71-one-scanner-write-reads-display-forms/expected.json +119 -0
  86. package/conformance/cases/c71-one-scanner-write-reads-display-forms/input.json +24 -0
  87. package/conformance/cases/c71-one-scanner-write-reads-display-forms/meta.json +10 -0
  88. package/conformance/cases/c72-dangling-reference-write-seat-refuses/expected.json +111 -0
  89. package/conformance/cases/c72-dangling-reference-write-seat-refuses/input.json +26 -0
  90. package/conformance/cases/c72-dangling-reference-write-seat-refuses/meta.json +6 -0
  91. package/conformance/cases/cc01-mcp-toolfaces-non-object/expected.json +3 -1
  92. package/conformance/cases/cc02-mcp-source-key-is-transported/expected.json +3 -1
  93. package/conformance/cases/cc03-model-compat-unknown-subkey/expected.json +6 -1
  94. package/conformance/cases/cli01-model-name-nfc-vs-nfd/expected.json +11 -1
  95. package/conformance/cases/cli02-control-and-zero-width-chars/expected.json +11 -1
  96. package/conformance/cases/cli03-credential-pasted-into-unknown-key/expected.json +16 -2
  97. package/conformance/cases/cli04-env-value-with-newline/expected.json +3 -1
  98. package/conformance/cases/cli05-long-and-prefix-colliding-names/expected.json +6 -1
  99. package/conformance/cases/t01-absent-domain-key-not-null/expected.json +3 -1
  100. package/conformance/cases/t03-prototype-name-key-not-swallowed/expected.json +29 -2
  101. package/conformance/cases/t04-secret-literal-never-in-warning-value/expected.json +3 -1
  102. package/conformance/cases/z01-every-domain-empty-object/expected.json +8 -4
  103. package/conformance/cases/z01-every-domain-empty-object/input.json +0 -1
  104. package/conformance/cases/z01-every-domain-empty-object/meta.json +1 -1
  105. package/conformance/cases/z02-every-domain-unknown-key/expected.json +163 -23
  106. package/conformance/cases/z02-every-domain-unknown-key/input.json +0 -3
  107. package/conformance/cases/z02-every-domain-unknown-key/meta.json +1 -1
  108. package/dist/api/auth-bridge.d.ts +51 -181
  109. package/dist/api/auth-bridge.js +58 -33
  110. package/dist/api/auth.d.ts +37 -146
  111. package/dist/api/auth.js +16 -26
  112. package/dist/api/feedback.d.ts +87 -0
  113. package/dist/api/feedback.js +66 -0
  114. package/dist/api/pull-token.d.ts +32 -0
  115. package/dist/api/pull-token.js +88 -0
  116. package/dist/api/scopes.d.ts +82 -174
  117. package/dist/api/scopes.js +23 -18
  118. package/dist/atomic-write.d.ts +14 -0
  119. package/dist/atomic-write.js +60 -0
  120. package/dist/bundle.d.ts +15 -7
  121. package/dist/bundle.js +28 -27
  122. package/dist/cli.d.ts +3 -1
  123. package/dist/cli.js +149 -80
  124. package/dist/config-d-file.d.ts +14 -0
  125. package/dist/config-d-file.js +67 -0
  126. package/dist/config-fns.d.ts +87 -186
  127. package/dist/config-fns.js +207 -262
  128. package/dist/conformance/contract-adapter.d.ts +9 -2
  129. package/dist/conformance/contract-adapter.js +15 -7
  130. package/dist/conformance/corpus.d.ts +3 -0
  131. package/dist/conformance/corpus.js +5 -0
  132. package/dist/conformance/derive.d.ts +3 -0
  133. package/dist/conformance/index.d.ts +2 -2
  134. package/dist/conformance/index.js +2 -2
  135. package/dist/conformance/run.js +67 -22
  136. package/dist/conformance/types.d.ts +31 -15
  137. package/dist/conformance/types.js +5 -0
  138. package/dist/conformance/warnings.d.ts +1 -1
  139. package/dist/conformance/warnings.js +1 -4
  140. package/dist/cross-domain.d.ts +66 -22
  141. package/dist/cross-domain.js +164 -93
  142. package/dist/cross-session.d.ts +29 -11
  143. package/dist/default-model.d.ts +43 -0
  144. package/dist/default-model.js +34 -0
  145. package/dist/execution-lane-index.d.ts +4 -8
  146. package/dist/execution-lane-index.js +4 -8
  147. package/dist/execution-lanes.d.ts +31 -8
  148. package/dist/execution-lanes.js +23 -5
  149. package/dist/file-edit.d.ts +3 -3
  150. package/dist/file-edit.js +11 -18
  151. package/dist/file-store.d.ts +7 -6
  152. package/dist/file-store.js +37 -50
  153. package/dist/fleet.d.ts +364 -762
  154. package/dist/fleet.js +277 -224
  155. package/dist/hooks.d.ts +1151 -4737
  156. package/dist/hooks.js +59 -15
  157. package/dist/index.d.ts +10 -4
  158. package/dist/index.js +13 -7
  159. package/dist/integer.d.ts +11 -0
  160. package/dist/integer.js +11 -0
  161. package/dist/issue-path.d.ts +10 -0
  162. package/dist/issue-path.js +10 -0
  163. package/dist/local-load.d.ts +1 -1
  164. package/dist/local-load.js +4 -14
  165. package/dist/loopback.d.ts +26 -0
  166. package/dist/loopback.js +54 -0
  167. package/dist/migration-receipt.d.ts +42 -0
  168. package/dist/migration-receipt.js +12 -0
  169. package/dist/one-time-migrations.d.ts +62 -0
  170. package/dist/one-time-migrations.js +828 -0
  171. package/dist/prompts-digest.d.ts +1 -1
  172. package/dist/prompts-digest.js +3 -2
  173. package/dist/reader.d.ts +2 -2
  174. package/dist/remote-exec.d.ts +45 -187
  175. package/dist/remote-exec.js +16 -10
  176. package/dist/resolve-roster.d.ts +18 -3
  177. package/dist/resolve-roster.js +28 -25
  178. package/dist/retired-envs.d.ts +24 -0
  179. package/dist/retired-envs.js +114 -0
  180. package/dist/retired-form.d.ts +31 -0
  181. package/dist/retired-form.js +28 -0
  182. package/dist/safety-merge-spec.d.ts +131 -191
  183. package/dist/safety-merge-spec.js +18 -14
  184. package/dist/scheduler-lifetime.d.ts +5 -0
  185. package/dist/scheduler-lifetime.js +7 -0
  186. package/dist/scheduler-store-migration.d.ts +14 -0
  187. package/dist/scheduler-store-migration.js +14 -0
  188. package/dist/scheduler-store-node.d.ts +28 -12
  189. package/dist/scheduler-store-node.js +32 -23
  190. package/dist/scheduler-store.d.ts +47 -14
  191. package/dist/scheduler-store.js +32 -61
  192. package/dist/secret-refs.d.ts +3 -3
  193. package/dist/secret-refs.js +1 -1
  194. package/dist/secret-scan.d.ts +14 -24
  195. package/dist/secret-scan.js +45 -23
  196. package/dist/sha256-core.d.ts +16 -0
  197. package/dist/sha256-core.js +138 -0
  198. package/dist/sha256.d.ts +6 -1
  199. package/dist/sha256.js +8 -98
  200. package/dist/skills-manifest.js +1 -1
  201. package/dist/types.d.ts +1509 -13536
  202. package/dist/types.js +526 -438
  203. package/dist/url-shape.d.ts +34 -0
  204. package/dist/url-shape.js +61 -0
  205. package/dist/web-admin-credential.d.ts +96 -0
  206. package/dist/{center-credential.js → web-admin-credential.js} +49 -46
  207. package/dist/web-admin-env.d.ts +33 -0
  208. package/dist/web-admin-env.js +33 -0
  209. package/dist/worker-row-verdict.d.ts +16 -0
  210. package/dist/worker-row-verdict.js +31 -0
  211. package/dist/workers-rows.d.ts +34 -0
  212. package/dist/workers-rows.js +42 -0
  213. package/dist/zod-unwrap.d.ts +11 -0
  214. package/dist/zod-unwrap.js +25 -0
  215. package/package.json +17 -9
  216. package/conformance/cases/c46-s811-host-name-url-userinfo-characterization/meta.json +0 -6
  217. package/conformance/cases/c47-s811-roles-key-url-userinfo-characterization/meta.json +0 -6
  218. package/conformance/migrate-workers/m01-g5-k8s-env-lifted/expected.json +0 -49
  219. package/conformance/migrate-workers/m01-g5-k8s-env-lifted/input.json +0 -26
  220. package/conformance/migrate-workers/m01-g5-k8s-env-lifted/meta.json +0 -4
  221. package/conformance/migrate-workers/m02-g5-idempotent/expected.json +0 -38
  222. package/conformance/migrate-workers/m02-g5-idempotent/input.json +0 -33
  223. package/conformance/migrate-workers/m02-g5-idempotent/meta.json +0 -5
  224. package/conformance/migrate-workers/m03-g5-refused-no-lane/expected.json +0 -71
  225. package/conformance/migrate-workers/m03-g5-refused-no-lane/input.json +0 -42
  226. package/conformance/migrate-workers/m03-g5-refused-no-lane/meta.json +0 -4
  227. package/conformance/migrate-workers/m04-g7b-session-tidb-lifted/expected.json +0 -52
  228. package/conformance/migrate-workers/m04-g7b-session-tidb-lifted/input.json +0 -30
  229. package/conformance/migrate-workers/m04-g7b-session-tidb-lifted/meta.json +0 -4
  230. package/conformance/migrate-workers/m05-row-verdict-reserved-env/expected.json +0 -82
  231. package/conformance/migrate-workers/m05-row-verdict-reserved-env/input.json +0 -68
  232. package/conformance/migrate-workers/m05-row-verdict-reserved-env/meta.json +0 -4
  233. package/dist/center-credential.d.ts +0 -116
  234. package/dist/issue-text.d.ts +0 -10
  235. package/dist/issue-text.js +0 -8
  236. package/dist/migrate-workers.d.ts +0 -71
  237. package/dist/migrate-workers.js +0 -351
  238. package/dist/migrate.d.ts +0 -255
  239. package/dist/migrate.js +0 -542
  240. /package/conformance/cases/{c46-s811-host-name-url-userinfo-characterization → c46-s811-host-name-url-userinfo-withheld}/input.json +0 -0
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * #14 SAFETY MERGE-SPEC — the ONE machine-readable per-category merge/enforcement table that every settings
3
- * resolver (TOC shell-adapter + TOB center/service) reads, so they never re-implement (and drift on) the merge
3
+ * resolver (TOC shell-adapter + TOB web-admin/service) reads, so they never re-implement (and drift on) the merge
4
4
  * law. This formalizes settings-core's v2-design §3 merge-spec into a published-data contract; the SEMANTIC
5
- * author is settings-core, center maintains the formalized artifact here (registry-core), the axis VALUE domains
5
+ * author is settings-core, web-admin maintains the formalized artifact here (this package), the axis VALUE domains
6
6
  * are core's (@sema-agent/core SAFETY_AXIS_VOCABULARY — validated by a dev-only conformance test, never a runtime
7
- * import). Each consumer keeps its OWN key→category map (client's SemaSettings KEY_CONTRACTS, center's
7
+ * import). Each consumer keeps its OWN key→category map (client's SemaSettings KEY_CONTRACTS, web-admin's
8
8
  * config-field→category map); THIS file is only the shared category spec they all cite — no third copy of the law.
9
9
  *
10
10
  * Per-category behavior is fully determined by `mergeShape` + `minTrust` + `tighten` + `enforcement` + `fail` — and,
@@ -118,14 +118,16 @@ export const SpecEnforcement = z.enum(["gate", "server", "resolver", "client"]);
118
118
  * enforcement on NON-secret keys (additive merges like mcpServers/rules/env); `gate`/`server`/secret → CLOSED. */
119
119
  export const FailMode = z.enum(["closed", "open"]);
120
120
  /** The monotone tighten-only direction for a category (which way is "stricter"). `enumRank.order` lists values
121
- * from LOOSEST→STRICTEST; `boolStricter.strict` is the bool value that is the tighter one; `setNarrower` = a
122
- * subset is tighter; `policyDenyWins` = deny beats allow; `none` = not rank-tightenable (replace / conflict-reject). */
121
+ * from LOOSEST→STRICTEST — the category's whole vocabulary: a word outside it is refused, and a ONE-word order (11.0.0:
122
+ * `shellGate`, whose engine vocabulary is the single floor word) is the degenerate rank where the only merge is identity;
123
+ * `boolStricter.strict` is the bool value that is the tighter one; `setNarrower` = a subset is tighter; `policyDenyWins` =
124
+ * deny beats allow; `none` = not rank-tightenable (replace / conflict-reject). */
123
125
  export const TightenRule = z.discriminatedUnion("rule", [
124
126
  z.object({ rule: z.literal("none") }),
125
127
  z.object({ rule: z.literal("policyDenyWins") }),
126
128
  z.object({ rule: z.literal("setNarrower") }),
127
129
  z.object({ rule: z.literal("boolStricter"), strict: z.boolean() }),
128
- z.object({ rule: z.literal("enumRank"), order: z.array(z.string()).min(2) }),
130
+ z.object({ rule: z.literal("enumRank"), order: z.array(z.string()).min(1) }),
129
131
  ]);
130
132
  export const MergeCategorySpec = z.object({
131
133
  mergeShape: MergeShape,
@@ -158,15 +160,17 @@ export const SAFETY_MERGE_SPEC = {
158
160
  ask: { direction: "tighten", minTrust: "local" },
159
161
  },
160
162
  },
161
- // [3759] order covers the FULL shell-addressable mode vocabulary — a word missing here is not "unknown", it
162
- // makes every two-layer merge containing it hit the out-of-vocabulary reject arm (the whole bundle used to be
163
- // dropped; shells now reject per-unit, but the semantic fix is a complete rank). Rank anchors (server
164
- // task-settings.ts translation): bypass ⇒ shellGate off; auto ⇒ classify with classifier auto-release over ANY
165
- // benign ask (⊇ acceptEdits' edits-only auto-allow); dontAsk = every would-ask → deny (allow rules still pass,
166
- // tighter than default, looser than plan's handsReadOnly).
163
+ // Order covers the FULL shell-addressable mode vocabulary — a word missing here is not "unknown", it makes every
164
+ // two-layer merge containing it hit the out-of-vocabulary reject arm (the whole bundle used to be dropped; shells now
165
+ // reject per-unit, but the semantic fix is a complete rank). Rank anchors (the engine's mode arbitration): bypass
166
+ // releases every releasable ask; auto hands them to the classifier, which may auto-release ANY benign ask (⊇
167
+ // acceptEdits' edits-only auto-allow); dontAsk = every would-ask → deny (allow rules still pass, tighter than default,
168
+ // looser than plan's handsReadOnly).
167
169
  permissionMode: { mergeShape: "replace", minTrust: "global", tighten: { rule: "enumRank", order: ["bypassPermissions", "auto", "acceptEdits", "default", "dontAsk", "plan"] }, enforcement: "gate", fail: "closed", seam: "derive → toolPolicy / onAsk / handsReadOnly (§6.4); bypass = launch-flag only + MANAGED kill-switch" },
168
170
  handsReadOnly: { mergeShape: "replace", minTrust: "global", tighten: { rule: "boolStricter", strict: true }, enforcement: "gate", fail: "closed", seam: "handsReadOnly" },
169
- shellGate: { mergeShape: "replace", minTrust: "global", tighten: { rule: "enumRank", order: ["off", "classify", "always"] }, enforcement: "gate", fail: "closed", seam: "shellGate", coreAxis: "shellGate" },
171
+ // The engine's shell floor has ONE word, `"always"` (the operator mandates a per-call confirmation for every shell command);
172
+ // the earlier `off` / `classify` are refused at the engine's door, so a layer carrying either is out of vocabulary here too.
173
+ shellGate: { mergeShape: "replace", minTrust: "global", tighten: { rule: "enumRank", order: ["always"] }, enforcement: "gate", fail: "closed", seam: "shellGate", coreAxis: "shellGate" },
170
174
  approver: { mergeShape: "replace", minTrust: "global", tighten: { rule: "none" }, enforcement: "gate", fail: "closed", seam: "onAsk (conflict → reject)" },
171
175
  // S-59 件八 — member-level admission for the sandbox category (the shell's temporary "which sub-key counts
172
176
  // as tightening" table lifts upstream; its gate keeps only the VALUE-pole half as executing detail).
@@ -227,7 +231,7 @@ export const SAFETY_MERGE_SPEC = {
227
231
  memoryScopes: { mergeShape: "concat", minTrust: "global", tighten: { rule: "none" }, enforcement: "client", fail: "open", seam: "MemoryStore" },
228
232
  autoCompaction: { mergeShape: "replace", minTrust: "session", tighten: { rule: "none" }, enforcement: "client", fail: "open", seam: "compaction config" },
229
233
  ui: { mergeShape: "replace", minTrust: "local", tighten: { rule: "none" }, enforcement: "client", fail: "open", seam: "shell-only (theme/keybindings/statusLine) — never touches the engine" },
230
- org: { mergeShape: "replace", minTrust: "managed", tighten: { rule: "none" }, enforcement: "server", fail: "closed", seam: "config-center → governanceBaseline (SSO/SCIM/audit/budget, TOB-only)" },
234
+ org: { mergeShape: "replace", minTrust: "managed", tighten: { rule: "none" }, enforcement: "server", fail: "closed", seam: "web-admin → governanceBaseline (SSO/SCIM/audit/budget, TOB-only)" },
231
235
  };
232
236
  /** Own-property table lookup. The category union is compile-time only; a JavaScript or deserialized caller can
233
237
  * hand in `"__proto__"` / `"constructor"` / `"toString"`, which a bare index resolves through Object.prototype
@@ -0,0 +1,5 @@
1
+ /**
2
+ * 包内私有(不进主桶):调度记录「是不是会话链上的记录」的**一只**读法 —— `lifetime === "session"`;其余(含缺席)一律是 durable。
3
+ * 读口的过期清理(`parseSchedulerStore`)与一次性迁移 SS-M3 的 durable 键重铸读同一只,两处对同一条记录不可能给出两种答案。
4
+ */
5
+ export declare function isSessionLifetime(record: object): boolean;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * 包内私有(不进主桶):调度记录「是不是会话链上的记录」的**一只**读法 —— `lifetime === "session"`;其余(含缺席)一律是 durable。
3
+ * 读口的过期清理(`parseSchedulerStore`)与一次性迁移 SS-M3 的 durable 键重铸读同一只,两处对同一条记录不可能给出两种答案。
4
+ */
5
+ export function isSessionLifetime(record) {
6
+ return record.lifetime === "session";
7
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * 一次性迁移 **SS-M3**(调度店 durable 键)的改写步 —— 包内私有,由 `./one-time-migrations` 以 `migrateSchedulerRecords` 再导出;
3
+ * `parseSchedulerStore` 在读时调它(lazy-on-read,登记在迁移表里的唯一读时改写;窗关随迁移族删)。单拆成叶子是为了让轻量的
4
+ * `./scheduler-store` 契约不把整套域 schema 拉进来。
5
+ *
6
+ * 规则(= 改写前每次 parse 都在做的生效读法,零语义变):`lifetime` 不是 `"session"` 的行(缺席 = durable)按 durable 铸键
7
+ * `scope := principal ?? "default"`;`"session"` 行是会话链键,不碰。只认形:改写后的行判据自不满足 ⇒ 重跑恒等。
8
+ */
9
+ import type { MigrationReceipt } from "./migration-receipt.js";
10
+ import type { SchedulerRecord } from "./scheduler-store.js";
11
+ export declare function migrateSchedulerRecords(records: readonly SchedulerRecord[]): {
12
+ records: SchedulerRecord[];
13
+ receipts: MigrationReceipt[];
14
+ };
@@ -0,0 +1,14 @@
1
+ import { isSessionLifetime } from "./scheduler-lifetime.js";
2
+ export function migrateSchedulerRecords(records) {
3
+ const receipts = [];
4
+ const out = records.map((r, index) => {
5
+ if (isSessionLifetime(r))
6
+ return r;
7
+ const want = r.principal ?? "default";
8
+ if (r.scope === want)
9
+ return r;
10
+ receipts.push({ migration: "SS-M3", document: "scheduler-store", path: [index, "scope"], appliedAs: "rewritten" });
11
+ return { ...r, scope: want };
12
+ });
13
+ return { records: out, receipts };
14
+ }
@@ -1,16 +1,18 @@
1
- import { type SchedulerRecord, type SchedulerStore } from './scheduler-store.js';
1
+ import { type SchedulerParseWarning, type SchedulerReadOptions, type SchedulerRecord, type SchedulerStore, type SchedulerStoreRead } from './scheduler-store.js';
2
2
  /**
3
- * Load the durable store. A MISSING file (ENOENT) ⇒ `[]` (fail-soft, first-run/never-written case); any OTHER
3
+ * Load the durable store. A MISSING file (ENOENT) ⇒ no records, no warnings (first-run/never-written case); any OTHER
4
4
  * read failure (EACCES/EIO/EISDIR/…) RETHROWS — only absence means "empty", not "unreadable for any reason"
5
5
  * (a permissions/IO fault silently treated as [] would let a caller-side save() overwrite real on-disk data with
6
- * an empty store). The pure parser drops corrupt individual records and preserves well-formed ones
7
- * (forward-compatible) — that degradation stays scoped to malformed CONTENT, not to read failures.
6
+ * an empty store). Content the parser could not read verbatim comes back in `warnings` ({@link parseSchedulerStore});
7
+ * a whole file that is not a JSON array reads as zero records + `unreadable-file` — NOT as an empty store (writers must go
8
+ * through {@link mutateSchedulerStore}, which refuses to write over it).
8
9
  */
9
- export declare function loadSchedulerStore(path: string): SchedulerRecord[];
10
+ export declare function loadSchedulerStore(path: string, opts: SchedulerReadOptions): SchedulerStoreRead;
10
11
  /**
11
- * Persist ATOMICALLY: `mkdir -p` the parent, write a uniquely-named `.tmp` sibling (mode 0o600 — owner-only, the
12
- * records may carry principal/session identifiers), then `rename` over the target (atomic on POSIX, so a
13
- * concurrent reader never sees a partial file). Throws on IO failure (caller maps to a domain error).
12
+ * Persist ATOMICALLY: `mkdir -p` the parent, then the package's one atomic writer (`./atomic-write`: a uniquely-named `.tmp`
13
+ * sibling, then `rename` over the target — atomic on POSIX, so a concurrent reader never sees a partial file). The file mode
14
+ * is the `scheduler-store` class's (0o600 — owner-only, the records may carry principal/session identifiers; every save
15
+ * restores it). Throws on IO failure (caller maps to a domain error).
14
16
  */
15
17
  export declare function saveSchedulerStore(path: string, records: SchedulerRecord[]): void;
16
18
  /** A filesystem-backed {@link SchedulerStore} bound to a path (convenience for the daemon/backend constructors).
@@ -19,10 +21,14 @@ export declare function saveSchedulerStore(path: string, records: SchedulerRecor
19
21
  * 的**活对象** ⇒ 按本组工厂命名法(`build*` 出纯数据、`create*` 出活对象)该叫 `create*`;2.0.0 那次照人眼
20
22
  * 改只改到了 `createSiblingResolver`,漏了这处 —— 现在由 `test/factory-naming-4.0.0.test.ts` 的门④ 机器看着。
21
23
  * 消费方改调 `createFileSchedulerStore`(改名是硬 breaking:消费方的 tsc 红 / 运行期 undefined 就是通知本身)。 */
22
- export declare function createFileSchedulerStore(path: string): SchedulerStore;
24
+ export declare function createFileSchedulerStore(path: string, opts: {
25
+ now: () => number;
26
+ }): SchedulerStore;
23
27
  /** Outcome of {@link mutateSchedulerStore}. `conflict_exhausted` = a concurrent writer kept changing the file
24
- * faster than the bounded replays; nothing was written on that final attempt (earlier attempts never wrote). */
25
- export type SchedulerStoreMutation = {
28
+ * faster than the bounded replays; nothing was written on that final attempt (earlier attempts never wrote).
29
+ * `unreadable_file` = the file on disk is not a JSON array: `fn` never ran and nothing was written (writing would replace
30
+ * data this reader cannot read with `fn`'s output). Every outcome carries the `warnings` of the last base read. */
31
+ export type SchedulerStoreMutation = ({
26
32
  ok: true;
27
33
  records: SchedulerRecord[];
28
34
  retries: number;
@@ -33,6 +39,11 @@ export type SchedulerStoreMutation = {
33
39
  } | {
34
40
  ok: false;
35
41
  reason: 'aborted';
42
+ } | {
43
+ ok: false;
44
+ reason: 'unreadable_file';
45
+ }) & {
46
+ warnings: SchedulerParseWarning[];
36
47
  };
37
48
  /**
38
49
  * The ONE cross-process mutation primitive for the shared scheduler store ([ref]①/② —
@@ -54,7 +65,12 @@ export type SchedulerStoreMutation = {
54
65
  * eliminate it (that would need an advisory lock protocol every writer joins; rejected for
55
66
  * stale-lock ops burden + win32 friction). `fn` re-runs on replay: side effects inside `fn` are a
56
67
  * caller bug.
68
+ *
69
+ * 11.0.0: this is the ONE write primitive every writer goes through (the naked load→save sequence is the lost-update race
70
+ * above). A base file that is not a JSON array ⇒ `unreadable_file`, no write. `nowMs` is the caller's clock (the read drops
71
+ * session-pinned records that went stale — see `parseSchedulerStore`).
57
72
  */
58
- export declare function mutateSchedulerStore(path: string, fn: (records: SchedulerRecord[]) => SchedulerRecord[] | null, opts?: {
73
+ export declare function mutateSchedulerStore(path: string, fn: (records: SchedulerRecord[]) => SchedulerRecord[] | null, opts: {
74
+ nowMs: number;
59
75
  retries?: number;
60
76
  }): SchedulerStoreMutation;
@@ -1,42 +1,44 @@
1
1
  /**
2
2
  * @sema-agent/settings-schema/node — the Node filesystem BINDING of the scheduler-store contract. Split out of the
3
- * pure barrel (it pulls `node:fs`/`node:path`/`node:crypto`) per registry-core's browser-safe discipline. Both
3
+ * pure barrel (it pulls `node:fs`/`node:path`/`node:crypto`) per this package's browser-safe discipline. Both
4
4
  * the server-side writer and the TOC shell daemon import THIS for the on-disk store; the FORMAT lives in the
5
5
  * pure `./scheduler-store` contract, so there is one shape and no drift.
6
6
  *
7
7
  * Store path = caller-supplied; both sides agree on `SEMA_CONFIG_DIR` → `~/.sema/scheduled_tasks.json`.
8
8
  */
9
- import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
9
+ import { mkdirSync, readFileSync } from 'node:fs';
10
10
  import { dirname } from 'node:path';
11
- import { randomBytes } from 'node:crypto';
11
+ import { writeFileAtomic } from './atomic-write.js';
12
12
  import { parseSchedulerStore, serializeSchedulerStore } from './scheduler-store.js';
13
13
  /**
14
- * Load the durable store. A MISSING file (ENOENT) ⇒ `[]` (fail-soft, first-run/never-written case); any OTHER
14
+ * Load the durable store. A MISSING file (ENOENT) ⇒ no records, no warnings (first-run/never-written case); any OTHER
15
15
  * read failure (EACCES/EIO/EISDIR/…) RETHROWS — only absence means "empty", not "unreadable for any reason"
16
16
  * (a permissions/IO fault silently treated as [] would let a caller-side save() overwrite real on-disk data with
17
- * an empty store). The pure parser drops corrupt individual records and preserves well-formed ones
18
- * (forward-compatible) — that degradation stays scoped to malformed CONTENT, not to read failures.
17
+ * an empty store). Content the parser could not read verbatim comes back in `warnings` ({@link parseSchedulerStore});
18
+ * a whole file that is not a JSON array reads as zero records + `unreadable-file` — NOT as an empty store (writers must go
19
+ * through {@link mutateSchedulerStore}, which refuses to write over it).
19
20
  */
20
- export function loadSchedulerStore(path) {
21
+ export function loadSchedulerStore(path, opts) {
22
+ let text;
21
23
  try {
22
- return parseSchedulerStore(readFileSync(path, 'utf-8'));
24
+ text = readFileSync(path, 'utf-8');
23
25
  }
24
26
  catch (e) {
25
27
  if (e.code === 'ENOENT')
26
- return [];
28
+ return { records: [], warnings: [] };
27
29
  throw e;
28
30
  }
31
+ return parseSchedulerStore(text, opts);
29
32
  }
30
33
  /**
31
- * Persist ATOMICALLY: `mkdir -p` the parent, write a uniquely-named `.tmp` sibling (mode 0o600 — owner-only, the
32
- * records may carry principal/session identifiers), then `rename` over the target (atomic on POSIX, so a
33
- * concurrent reader never sees a partial file). Throws on IO failure (caller maps to a domain error).
34
+ * Persist ATOMICALLY: `mkdir -p` the parent, then the package's one atomic writer (`./atomic-write`: a uniquely-named `.tmp`
35
+ * sibling, then `rename` over the target — atomic on POSIX, so a concurrent reader never sees a partial file). The file mode
36
+ * is the `scheduler-store` class's (0o600 — owner-only, the records may carry principal/session identifiers; every save
37
+ * restores it). Throws on IO failure (caller maps to a domain error).
34
38
  */
35
39
  export function saveSchedulerStore(path, records) {
36
40
  mkdirSync(dirname(path), { recursive: true });
37
- const tmp = `${path}.${randomBytes(6).toString('hex')}.tmp`;
38
- writeFileSync(tmp, serializeSchedulerStore(records), { mode: 0o600 });
39
- renameSync(tmp, path); // atomic over the target
41
+ writeFileAtomic(path, serializeSchedulerStore(records), 'scheduler-store');
40
42
  }
41
43
  /** A filesystem-backed {@link SchedulerStore} bound to a path (convenience for the daemon/backend constructors).
42
44
  *
@@ -44,9 +46,9 @@ export function saveSchedulerStore(path, records) {
44
46
  * 的**活对象** ⇒ 按本组工厂命名法(`build*` 出纯数据、`create*` 出活对象)该叫 `create*`;2.0.0 那次照人眼
45
47
  * 改只改到了 `createSiblingResolver`,漏了这处 —— 现在由 `test/factory-naming-4.0.0.test.ts` 的门④ 机器看着。
46
48
  * 消费方改调 `createFileSchedulerStore`(改名是硬 breaking:消费方的 tsc 红 / 运行期 undefined 就是通知本身)。 */
47
- export function createFileSchedulerStore(path) {
49
+ export function createFileSchedulerStore(path, opts) {
48
50
  return {
49
- load: () => loadSchedulerStore(path),
51
+ load: () => loadSchedulerStore(path, { nowMs: opts.now() }),
50
52
  save: (records) => saveSchedulerStore(path, records),
51
53
  };
52
54
  }
@@ -70,9 +72,13 @@ export function createFileSchedulerStore(path) {
70
72
  * eliminate it (that would need an advisory lock protocol every writer joins; rejected for
71
73
  * stale-lock ops burden + win32 friction). `fn` re-runs on replay: side effects inside `fn` are a
72
74
  * caller bug.
75
+ *
76
+ * 11.0.0: this is the ONE write primitive every writer goes through (the naked load→save sequence is the lost-update race
77
+ * above). A base file that is not a JSON array ⇒ `unreadable_file`, no write. `nowMs` is the caller's clock (the read drops
78
+ * session-pinned records that went stale — see `parseSchedulerStore`).
73
79
  */
74
80
  export function mutateSchedulerStore(path, fn, opts) {
75
- const maxRetries = Math.max(0, opts?.retries ?? 5);
81
+ const maxRetries = Math.max(0, opts.retries ?? 5);
76
82
  const rawOf = () => {
77
83
  try {
78
84
  return readFileSync(path, 'utf-8');
@@ -86,16 +92,19 @@ export function mutateSchedulerStore(path, fn, opts) {
86
92
  };
87
93
  for (let attempt = 0;; attempt++) {
88
94
  const baseRaw = rawOf();
89
- const records = baseRaw === null ? [] : parseSchedulerStore(baseRaw);
90
- const next = fn(records);
95
+ const base = baseRaw === null ? { records: [], warnings: [] } : parseSchedulerStore(baseRaw, { nowMs: opts.nowMs });
96
+ const { warnings } = base;
97
+ if (warnings.some((w) => w.kind === 'unreadable-file'))
98
+ return { ok: false, reason: 'unreadable_file', warnings };
99
+ const next = fn(base.records);
91
100
  if (next === null)
92
- return { ok: false, reason: 'aborted' };
101
+ return { ok: false, reason: 'aborted', warnings };
93
102
  if (rawOf() !== baseRaw) {
94
103
  if (attempt >= maxRetries)
95
- return { ok: false, reason: 'conflict_exhausted', retries: attempt };
104
+ return { ok: false, reason: 'conflict_exhausted', retries: attempt, warnings };
96
105
  continue; // a concurrent writer landed between our read and commit intent — replay on the new base
97
106
  }
98
107
  saveSchedulerStore(path, next);
99
- return { ok: true, records: next, retries: attempt };
108
+ return { ok: true, records: next, retries: attempt, warnings };
100
109
  }
101
110
  }
@@ -17,6 +17,7 @@
17
17
  * 🔴 `principal` is pinned by the writer from Runner-held ctx (never the model); persisted opaquely, passed
18
18
  * through to `runTask` unchanged by the daemon (no privilege escalation).
19
19
  */
20
+ import type { MigrationReceipt } from './migration-receipt.js';
20
21
  /** A scheduled `when` — structurally identical to core `ScheduledIntent['when']`, inlined (contract stands alone). */
21
22
  export type SchedulerWhen = {
22
23
  kind: 'cron';
@@ -47,31 +48,63 @@ export interface SchedulerRecord {
47
48
  /**
48
49
  * The store ABSTRACTION the two sides depend on (writer saves, reader loads). The `@sema-agent/settings-schema/node`
49
50
  * file binding implements it; a server with a DB backend could implement it differently against the same shape.
51
+ * `load()` returns the records AND what the read did to them ({@link SchedulerStoreRead}) — there is no silent read.
50
52
  */
51
53
  export interface SchedulerStore {
52
- load(): SchedulerRecord[];
54
+ load(): SchedulerStoreRead;
53
55
  save(records: SchedulerRecord[]): void;
54
56
  }
55
57
  /** Structural validation of one record (required fields + a well-formed `when`). Unknown fields are allowed. */
56
58
  export declare function isSchedulerRecord(v: unknown): v is SchedulerRecord;
57
- /** Something {@link parseSchedulerStore} DID to the data it read, rather than reading it verbatim. `index` =
58
- * the record's position in the parsed array (for correlating with the source file). Additive — passing
59
- * `onWarning` is optional, and a new `kind` is an additive union member (a consumer with an exhaustive
60
- * `switch` gains an arm to write).
61
- * - `malformed-record` — the record failed {@link isSchedulerRecord} and was dropped; `record` = the
62
- * raw (unvalidated) value, for diagnostics.
63
- * - `durable-scope-rekeyed` — the durable-scope compat arm rewrote this row's `scope` (2.0.0; before that
64
- * the rewrite happened silently every parse). */
59
+ /** 一条会话链上的记录过期多久算「永驻孤儿」:`lifetime: "session"` ∧ `sessionId` 在场 ∧ `nextRunMs` 已过去这么久 ⇒ 读口丢弃并告警(7 天)。 */
60
+ export declare const STALE_SESSION_RECORD_MS: number;
61
+ /** {@link parseSchedulerStore} 对它读到的数据**做了什么**(而不是逐字读)。闭集 —— 新 `kind` 是联合加员(穷举 `switch` 的消费方
62
+ * 会编译红,那就是通知)。`index` = 记录在原数组里的下标(对照源文件用)。
63
+ * - `unreadable-file` — 整份文件不是 JSON / 不是数组:读出零条记录。**不是**「空店」:{@link mutateSchedulerStore} 遇它拒写
64
+ * (否则下一次保存会把盘上那份读不懂的数据整份覆盖掉)。
65
+ * - `malformed-record` — 这一条没过 {@link isSchedulerRecord},丢弃;`record` = 原值(未校验,诊断用)。
66
+ * - `stale-session-record` — 会话链上的记录(`lifetime: "session"` ∧ `sessionId` 在场)而 `nextRunMs` 已过去 ≥ {@link STALE_SESSION_RECORD_MS}:
67
+ * 它的会话没走到收割(无常驻 daemon 的车道 / 崩溃),再留着就是永驻。丢弃,不另起写 —— 下一个写者落盘。
68
+ * **只认显式的 `"session"`**:`lifetime` 缺席读作 durable(与 SS-M3 同一只读法);durable 记录带着来源 sessionId
69
+ * 是合法形,daemon 停机一周后的 cron 照样是用户的任务,不许当孤儿丢。
70
+ * - `one-time-migration` — 登记过窗的一次性迁移在读时改写了这一条(今天只有 SS-M3:durable 行按旧会话键存 ⇒ 重铸成
71
+ * `principal ?? "default"`);`receipt` 是迁移的收据(`./one-time-migrations` 的同一形)。 */
65
72
  export type SchedulerParseWarning = {
66
- index: number;
73
+ kind: 'unreadable-file';
74
+ why: 'not-json' | 'not-an-array';
75
+ } | {
67
76
  kind: 'malformed-record';
77
+ index: number;
68
78
  record: unknown;
69
79
  } | {
80
+ kind: 'stale-session-record';
81
+ index: number;
82
+ id: string;
83
+ sessionId: string;
84
+ nextRunMs: number;
85
+ } | {
86
+ kind: 'one-time-migration';
70
87
  index: number;
71
- kind: 'durable-scope-rekeyed';
72
- from: string;
73
- to: string;
88
+ receipt: MigrationReceipt;
74
89
  };
75
- export declare function parseSchedulerStore(text: string, onWarning?: (w: SchedulerParseWarning) => void): SchedulerRecord[];
90
+ /** 一次读的结果:留下的记录 + 读时做过的每一件事。没有「静默模式」—— 告警恒随结果返回,调用方决定记到哪。 */
91
+ export interface SchedulerStoreRead {
92
+ readonly records: SchedulerRecord[];
93
+ readonly warnings: SchedulerParseWarning[];
94
+ }
95
+ /** 读口的入参:`nowMs` 由调用方给(本包不读墙钟),判 {@link STALE_SESSION_RECORD_MS}。 */
96
+ export interface SchedulerReadOptions {
97
+ readonly nowMs: number;
98
+ }
99
+ /**
100
+ * Parse the durable store from its serialized form. FORWARD-COMPATIBLE: well-formed records keep ALL their fields (incl.
101
+ * unknown future ones) so a newer writer's data survives an older reader's round-trip. Every deviation from a verbatim
102
+ * read comes back in `warnings` ({@link SchedulerParseWarning}) — 11.0.0 removed the silent mode (the warning callback
103
+ * used to be optional, and the default read paths never passed it, so corrupt records vanished without a trace).
104
+ *
105
+ * 读时改写只有一种:登记在一次性迁移表里、窗内存活的 SS-M3(`migrateSchedulerRecords`,lazy-on-read —— 不另起写,由下一个写者
106
+ * 落盘;另起一次写只会多一个竞争写者)。窗关后随迁移族一起删。
107
+ */
108
+ export declare function parseSchedulerStore(text: string, opts: SchedulerReadOptions): SchedulerStoreRead;
76
109
  /** Serialize the store to its canonical on-disk form (stable, pretty JSON). The WRITE half of the format contract. */
77
110
  export declare function serializeSchedulerStore(records: SchedulerRecord[]): string;
@@ -1,22 +1,5 @@
1
- /**
2
- * @sema-agent/settings-schema/scheduler-store — the PURE, dep-free CONTRACT for the self-wake scheduler store
3
- * (R7 自唤醒). The shared seam between the WRITER (server-side `SchedulerCapability`, persists the model's
4
- * CronCreate/Sleep intents) and the READER (TOC shell `SchedulerDaemon`, fires due intents). One canonical
5
- * on-disk SHAPE + parse/serialize ⇒ the two independently-built sides never drift on the format.
6
- *
7
- * 🔴 Contract discipline (this is a third-party contract lib, not an impl dump):
8
- * - ABSTRACT: this module is the type + the parse/serialize FORMAT + the `SchedulerStore` interface. It has
9
- * ZERO runtime IO and ZERO deps (no `@sema-agent/*`, no `node:*`) so it lives in the browser-safe barrel.
10
- * The concrete filesystem binding is `@sema-agent/settings-schema/node` (`loadSchedulerStore`/`saveSchedulerStore`).
11
- * - EXTENSIBLE: `parseSchedulerStore` is FORWARD-COMPATIBLE — it validates only the required shape and
12
- * PRESERVES unknown fields, so a newer writer can add fields without an older reader dropping them on
13
- * round-trip. New optional `SchedulerRecord` fields are additive (no breaking change).
14
- * - CONTRACTUAL: `when` is an inlined structural union kept byte-compatible with core `ScheduledIntent['when']`
15
- * but NOT imported — the contract stands alone, independent of the engine.
16
- *
17
- * 🔴 `principal` is pinned by the writer from Runner-held ctx (never the model); persisted opaquely, passed
18
- * through to `runTask` unchanged by the daemon (no privilege escalation).
19
- */
1
+ import { isSessionLifetime } from './scheduler-lifetime.js';
2
+ import { migrateSchedulerRecords } from './scheduler-store-migration.js';
20
3
  /** Structural validation of one record (required fields + a well-formed `when`). Unknown fields are allowed. */
21
4
  export function isSchedulerRecord(v) {
22
5
  if (typeof v !== 'object' || v === null)
@@ -35,63 +18,51 @@ export function isSchedulerRecord(v) {
35
18
  return typeof w.delaySec === 'number';
36
19
  return false;
37
20
  }
21
+ /** 一条会话链上的记录过期多久算「永驻孤儿」:`lifetime: "session"` ∧ `sessionId` 在场 ∧ `nextRunMs` 已过去这么久 ⇒ 读口丢弃并告警(7 天)。 */
22
+ export const STALE_SESSION_RECORD_MS = 7 * 24 * 60 * 60 * 1000;
38
23
  /**
39
- * Parse the durable store from its serialized form. FAIL-SOFT + FORWARD-COMPATIBLE: corrupt/non-array/non-JSON
40
- * ⇒ `[]`; malformed individual records are dropped (the store self-heals on next save); well-formed records keep
41
- * ALL their fields (incl. unknown future ones) so a newer writer's data survives an older reader's round-trip.
42
- * `onWarning` (optional, additive — mirrors the `FileConfigStore`/`EffectiveReadWarning` precedent): called once
43
- * per dropped record so a caller can surface the loss instead of it vanishing silently (C5/C6 — a dropped
44
- * record must leave a trace reachable by whoever can act on it).
45
- */
46
- /**
47
- * core 5.5.0 durable 键分叉的迁移半场([ref];server 裁 (a) 一次性迁移,拒读侧
48
- * 双键容忍窗)。旧铸键链 `sessionId ?? principal ?? taskId ?? "default"` 让 durable 行的隔离键随会话
49
- * 走——新会话新 sessionId ⇒ 旧 durable job 永远 not_found。core 5.5.0 起 durable 铸键=
50
- * `principal ?? "default"`;存量旧键行由**本层**在 parse 时重铸(纯函数,幂等:重写后判据自不满足)。
24
+ * Parse the durable store from its serialized form. FORWARD-COMPATIBLE: well-formed records keep ALL their fields (incl.
25
+ * unknown future ones) so a newer writer's data survives an older reader's round-trip. Every deviation from a verbatim
26
+ * read comes back in `warnings` ({@link SchedulerParseWarning}) — 11.0.0 removed the silent mode (the warning callback
27
+ * used to be optional, and the default read paths never passed it, so corrupt records vanished without a trace).
51
28
  *
52
- * 为什么放 parse 而不是 loader:`loadSchedulerStore` 与 `mutateSchedulerStore` 都各自经
53
- * `parseSchedulerStore`——归一放这里=两条读路径、两个读者(server 写侧 backend / shell daemon)
54
- * 单点同愈,无第二机制。盘面在下一次 save 收敛(读侧恒一致,不做读时写放大)。
55
- *
56
- * 窄读判据:`lifetime` 不在本契约的显式键上(server 写侧作为 forward-compat 字段随行),按未知
57
- * 字段窄读——`"session"` 行是会话链键,零触碰;其余(缺席=durable 默认)按 durable 归一。
58
- *
59
- * 落日(2.0.0):删除它 = **持久数据**面的破坏(仍按旧键存着的 durable 行会就此找不回),所以不能在
60
- * minor 上一删了事 —— 归下一个 MAJOR,按持久数据的规矩办(响亮拒收 + 一次性迁移)。在那之前,本版把
61
- * 这次重写从**静默**改成**出声**(`durable-scope-rekeyed`,带改写前后):关窗的前提是有人能证明它
62
- * 已经不再触发,而不是有人记得它还在。
29
+ * 读时改写只有一种:登记在一次性迁移表里、窗内存活的 SS-M3(`migrateSchedulerRecords`,lazy-on-read —— 不另起写,由下一个写者
30
+ * 落盘;另起一次写只会多一个竞争写者)。窗关后随迁移族一起删。
63
31
  */
64
- function normalizeDurableScope(r) {
65
- if (r.lifetime === 'session')
66
- return r;
67
- const want = r.principal ?? 'default';
68
- return r.scope === want ? r : { ...r, scope: want };
69
- }
70
- export function parseSchedulerStore(text, onWarning) {
32
+ export function parseSchedulerStore(text, opts) {
71
33
  let parsed;
72
34
  try {
73
35
  parsed = JSON.parse(text);
74
36
  }
75
37
  catch {
76
- return [];
38
+ return { records: [], warnings: [{ kind: 'unreadable-file', why: 'not-json' }] };
77
39
  }
78
40
  if (!Array.isArray(parsed))
79
- return [];
80
- if (!onWarning)
81
- return parsed.filter(isSchedulerRecord).map(normalizeDurableScope);
82
- const out = [];
41
+ return { records: [], warnings: [{ kind: 'unreadable-file', why: 'not-an-array' }] };
42
+ const warnings = [];
43
+ const kept = [];
44
+ const keptFrom = [];
83
45
  parsed.forEach((record, index) => {
84
46
  if (!isSchedulerRecord(record)) {
85
- onWarning({ index, kind: 'malformed-record', record });
47
+ warnings.push({ kind: 'malformed-record', index, record });
86
48
  return;
87
49
  }
88
- const normalized = normalizeDurableScope(record);
89
- // 兼容臂真改写了这一行才出声(未改写时 normalizeDurableScope 返回同一个引用)。
90
- if (normalized !== record)
91
- onWarning({ index, kind: 'durable-scope-rekeyed', from: record.scope, to: normalized.scope });
92
- out.push(normalized);
50
+ if (isSessionLifetime(record) && typeof record.sessionId === 'string' && record.sessionId !== '' && typeof record.nextRunMs === 'number' && opts.nowMs - record.nextRunMs >= STALE_SESSION_RECORD_MS) {
51
+ warnings.push({ kind: 'stale-session-record', index, id: record.id, sessionId: record.sessionId, nextRunMs: record.nextRunMs });
52
+ return;
53
+ }
54
+ kept.push(record);
55
+ keptFrom.push(index);
93
56
  });
94
- return out;
57
+ const migrated = migrateSchedulerRecords(kept);
58
+ for (const receipt of migrated.receipts) {
59
+ // 收据的坐标是迁移入参(`kept`)里的下标;换回原数组下标(对照源文件),与其余告警同一套坐标。
60
+ const at = receipt.path[0];
61
+ const index = typeof at === 'number' ? (keptFrom[at] ?? at) : -1;
62
+ warnings.push({ kind: 'one-time-migration', index, receipt: { ...receipt, path: [index, ...receipt.path.slice(1)] } });
63
+ }
64
+ warnings.sort((a, b) => ('index' in a ? a.index : -1) - ('index' in b ? b.index : -1));
65
+ return { records: migrated.records, warnings };
95
66
  }
96
67
  /** Serialize the store to its canonical on-disk form (stable, pretty JSON). The WRITE half of the format contract. */
97
68
  export function serializeSchedulerStore(records) {
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Secret-reference inventory (Phase-3 "密钥引用校验"). sema-registry stores only env-NAMEs, never secret
2
+ * Secret-reference inventory (Phase-3 "密钥引用校验"). web-admin stores only env-NAMEs, never secret
3
3
  * values (the ENV_NAME boundary). This collects EVERY env-NAME the effective config references, grouped by
4
4
  * name with where each is used — so an operator can audit "which env vars must each host/service provide?"
5
5
  * before a worker/model fails at runtime.
6
6
  *
7
- * Note: sema-registry can't verify a name actually EXISTS in a consumer's environment (that's the consumer's
7
+ * Note: web-admin can't verify a name actually EXISTS in a consumer's environment (that's the consumer's
8
8
  * own env). For workers, the reconciler already reports a missing one as a `SecretMissing` status condition
9
- * (surfaced on /fleet). This inventory is the center-side half — every referenced env-NAME, incl. the general
9
+ * (surfaced on /fleet). This inventory is web-admin-side half — every referenced env-NAME, incl. the general
10
10
  * `secretEnv` passthrough, so a rotation runbook built on it isn't blind to app-specific tokens.
11
11
  */
12
12
  import type { EffectiveConfig } from "./types.js";
@@ -22,7 +22,7 @@ export function collectEnvRefs(eff) {
22
22
  for (const [key, envName] of Object.entries(w.runtime.execution?.secretRefs ?? {}))
23
23
  add(envName, "workers", `worker "${w.name}".runtime.execution.secretRefs.${key}`);
24
24
  // the general secretEnv passthrough also references env-NAMEs (worker-env.ts merges them) — include them
25
- // so the rotation inventory isn't blind to app-specific tokens like OA_SERVICE_TOKEN→OA_CALLBACK_TOKEN.
25
+ // so the rotation inventory isn't blind to app-specific tokens like MY_APP_TOKEN→MY_APP_TOKEN_ENV.
26
26
  for (const [key, envName] of Object.entries(w.secretEnv ?? {}))
27
27
  add(envName, "workers", `worker "${w.name}".secretEnv.${key}`);
28
28
  }
@@ -1,28 +1,11 @@
1
- /**
2
- * 秘密扫描读的**归一化**,外加「URL 的 userinfo 就是凭据」那一只判定({@link carriesUserinfo})—— 包内私有模块(6.3.0):不进主 barrel、
3
- * 不进 `package.json` 的 `exports`,消费方拿不到。
4
- * 校验侧探测器 `containsSecretToken`(`./types`)只读 {@link percentLayers};展示侧脱敏器 `redactSecrets`(`./config-fns`)
5
- * 读 {@link secretScanForms}。展示侧的读法(边界读法、`+` 读法、「没走完」那一位)刻意不做公开契约:它们以后要随新发现的
6
- * 编码形调整,调整不该是契约变更(opus 独立腿 L7)。
7
- *
8
- * **单遍、零异常**(opus 独立腿 M1,对 6.2.0 的读路径 CPU 回归):6.2.0 起的宽容解码对每个 `%HH` 各 `try decodeURIComponent`,
9
- * 非 ASCII 的转义每个都抛一次异常(≈ 20–30 µs / 字节,同步阻塞);6.3.0 首稿又让探测器顺带算它不用的展示形。这里两处解码都
10
- * 不靠异常:单个转义的判据写成字符集(只有 ASCII 的 `%00`–`%7F` 单独解得开),转义串的判据写成 UTF-8 良构判定 ——
11
- * 结果与原来的 try/catch 逐字相同(`test/disclosable-identity-6.3.0.test.ts` 的两只冻结 oracle 钉着)。
12
- *
13
- * 本模块零 import(`./types` 引它,它不回引,不成环)。
14
- */
15
1
  /** How many percent-decoded layers a secret scan reads beyond the raw string (a multi-encoded `%2573%256B…` →
16
2
  * `%73%6B…` → `sk-…`). Bounded → deterministic. */
17
3
  export declare const SECRET_SCAN_DECODE_LAYERS = 3;
18
- /** The raw string first, then up to {@link SECRET_SCAN_DECODE_LAYERS} tolerant percent-decoded layers, stopping at the first
19
- * layer that decodes nothing — the forms the validation detector `containsSecretToken` reads, and nothing else. */
20
- export declare function percentLayers(text: string): readonly string[];
21
4
  /** What {@link secretScanForms} returns — plain data, no behavior. */
22
5
  export interface SecretScanForms {
23
6
  /** {@link percentLayers} of the text. */
24
7
  readonly percentLayers: readonly string[];
25
- /** The forms the display-side redactor judges a whole string on: `percentLayers`, each layer's {@link boundaryReading}, and
8
+ /** The forms a string is judged on (write and display alike): `percentLayers`, each layer's {@link boundaryReading}, and
26
9
  * each of those read the application/x-www-form-urlencoded way (`+` as a space); duplicates dropped, first seen first. */
27
10
  readonly forms: readonly string[];
28
11
  /** Normalization did not finish: the layer budget ran out while one more layer would still decode something. A caller that
@@ -30,10 +13,17 @@ export interface SecretScanForms {
30
13
  * point, not an unfinished one: `%E4%B8%AD` stays as it is and is not flagged.) */
31
14
  readonly unresolved: boolean;
32
15
  }
33
- /** The display-side normalization — only `redactSecrets` (config-fns) calls it. */
16
+ /** The normalization BOTH scanners read (11.0.0): the validation detector `containsSecretToken` (types) and the display redactor
17
+ * `redactSecrets` (config-fns). */
34
18
  export declare function secretScanForms(text: string): SecretScanForms;
35
- /** 「URL 的 userinfo 就是凭据」的**唯一**判定(6.3.0,opus 腿 L1):username / password 任一在场即是。校验侧 `NON_SECRET_URL` 与 https
36
- * 插件源据此拒写(`./types`),身份面据此否决(`./config-fns` 的 `isDisclosableIdentity` —— `https://admin:hunter2@db…` /
37
- * `postgres://svc:pw@h` 的字符全在身份白名单里)。串解析不成 URL ⇒ false:这只判定只说「一个 URL 带没带 userinfo」,
38
- * 不是 URL 的串不在它的射程里(无 scheme 的 `svc:pw@host` 解析成不透明路径,同样答 false —— 如实登记的边界)。 */
39
- export declare function carriesUserinfo(u: URL | string): boolean;
19
+ /** 「URL 的 userinfo 就是凭据」的**唯一**判定(6.3.0 起;11.0.0 S-811 扩到自由文本):文本是带 userinfo 的 URL(URL 形判官
20
+ * {@link httpUrlFlaws} 判出 `userinfo` 这一件 —— 层级式任意 scheme,`ssh://git@host` 只带用户名也算),或文本里某个 `@` 收尾的那一段
21
+ * (从 `@` 向前到空白或上一个 `@`)是凭据对:
22
+ * · authority 形 —— 段里最后一个 `//` 到 `@` 之间非空且不含 `/ ? #`(`jdbc:postgresql://svc:pw@…`、`//admin:pw@…`、`ssh://git@h`);
23
+ * · 冒号形 —— 段里最后一个 `/ ? #` 之后的那一截是 `user:pw`(两侧非空),且 `@` 后紧跟主机字符(`admin:pw@db:5432/app`、`postgres:/svc:pw@h`)。
24
+ * 引号 / 尖括号 / 反引号**不**断段(它们可以在口令里)。单遍:每个字符至多被一次向前扫描读到(段以上一个 `@` 为界、互不重叠)⇒ 线性,
25
+ * 不用回溯正则(codex r1:扫描正则在 `;` × 4 万 + `@` 上要 10 s)。
26
+ * 读者:展示侧 `redactSecrets`(`./config-fns`)对空白分隔的候选段与归一化形各问一次 —— 自由文本出门面(坐标 / 错误句 / 主机名标签)与身份面
27
+ * (`isDisclosableIdentity` 问的就是 `redactSecrets` 原样放不放行)同一条律。校验侧不读它:URL 座位的 userinfo 由 URL 形判官拒,自由文本写口
28
+ * 不因 userinfo 拒(契约未变)。负控:路径里的 `@`(`https://h/@scope/pkg`)、邮箱、`name@版本`、空 userinfo(`https://@h`)都不是。 */
29
+ export declare function carriesUserinfo(text: string): boolean;