@sema-agent/settings-schema 14.6.0 → 14.8.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 (95) hide show
  1. package/CHANGELOG.md +475 -0
  2. package/README.md +89 -5
  3. package/conformance/VERSION +1 -1
  4. package/conformance/cases/c84-sandbox-git-block-carried-verbatim/expected.json +153 -0
  5. package/conformance/cases/c84-sandbox-git-block-carried-verbatim/input.json +23 -0
  6. package/conformance/cases/c84-sandbox-git-block-carried-verbatim/meta.json +5 -0
  7. package/conformance/cases/c85-sandbox-git-refused-block-still-carried/expected.json +153 -0
  8. package/conformance/cases/c85-sandbox-git-refused-block-still-carried/input.json +23 -0
  9. package/conformance/cases/c85-sandbox-git-refused-block-still-carried/meta.json +5 -0
  10. package/dist/api/auth-bridge.d.ts +16 -2
  11. package/dist/api/auth-bridge.js +59 -24
  12. package/dist/api/credentials.d.ts +281 -0
  13. package/dist/api/credentials.js +149 -0
  14. package/dist/api/scopes.js +5 -1
  15. package/dist/atomic-write.d.ts +15 -0
  16. package/dist/atomic-write.js +109 -5
  17. package/dist/catalog-manages.d.ts +15 -0
  18. package/dist/catalog-manages.js +60 -0
  19. package/dist/center-refs.d.ts +68 -0
  20. package/dist/center-refs.js +70 -0
  21. package/dist/config-d-file.js +33 -15
  22. package/dist/config-dir-migration.js +1 -0
  23. package/dist/config-fns.js +147 -81
  24. package/dist/config-root-documents.d.ts +106 -0
  25. package/dist/config-root-documents.js +93 -0
  26. package/dist/config-root-node.d.ts +117 -0
  27. package/dist/config-root-node.js +260 -0
  28. package/dist/conformance/canonical.js +4 -4
  29. package/dist/conformance/contract-adapter.js +26 -1
  30. package/dist/conformance/corpus.js +4 -4
  31. package/dist/conformance/derive.js +2 -2
  32. package/dist/conformance/run.d.ts +3 -0
  33. package/dist/conformance/run.js +53 -12
  34. package/dist/conformance/temp-roots.d.ts +31 -0
  35. package/dist/conformance/temp-roots.js +184 -0
  36. package/dist/cross-session.d.ts +4 -4
  37. package/dist/declared-capture.d.ts +41 -0
  38. package/dist/declared-capture.js +344 -0
  39. package/dist/default-model.d.ts +1 -1
  40. package/dist/default-model.js +5 -4
  41. package/dist/env-model-table.d.ts +163 -0
  42. package/dist/env-model-table.js +257 -0
  43. package/dist/file-edit.js +11 -15
  44. package/dist/fleet.d.ts +36 -7
  45. package/dist/fleet.js +83 -32
  46. package/dist/hash.d.ts +0 -9
  47. package/dist/hash.js +64 -7
  48. package/dist/hooks-config.js +18 -3
  49. package/dist/http-hook-allowlists.d.ts +22 -9
  50. package/dist/http-hook-allowlists.js +169 -91
  51. package/dist/index.d.ts +7 -0
  52. package/dist/index.js +7 -0
  53. package/dist/mcp-request-entry.d.ts +67 -13
  54. package/dist/mcp-request-entry.js +132 -93
  55. package/dist/mcp-server-name.d.ts +17 -0
  56. package/dist/mcp-server-name.js +19 -0
  57. package/dist/migration-receipt.d.ts +10 -6
  58. package/dist/migration-receipt.js +5 -3
  59. package/dist/node.d.ts +1 -0
  60. package/dist/node.js +2 -0
  61. package/dist/one-time-migrations.d.ts +4 -2
  62. package/dist/one-time-migrations.js +236 -96
  63. package/dist/own-key-read.d.ts +247 -0
  64. package/dist/own-key-read.js +449 -0
  65. package/dist/plain-json-object.d.ts +11 -1
  66. package/dist/plain-json-object.js +37 -1
  67. package/dist/remote-exec.js +9 -1
  68. package/dist/retired-form.d.ts +19 -0
  69. package/dist/retired-form.js +24 -1
  70. package/dist/safety-merge-spec.d.ts +6 -6
  71. package/dist/safety-merge-spec.js +50 -37
  72. package/dist/sandbox-git.d.ts +64 -0
  73. package/dist/sandbox-git.js +89 -0
  74. package/dist/scheduler-store.js +19 -11
  75. package/dist/settings-units.js +14 -12
  76. package/dist/tier-words.d.ts +33 -0
  77. package/dist/tier-words.js +43 -0
  78. package/dist/types.d.ts +12 -2
  79. package/dist/types.js +35 -6
  80. package/dist/url-shape.d.ts +16 -0
  81. package/dist/url-shape.js +18 -0
  82. package/dist/web-admin-credential.js +5 -2
  83. package/dist/web-admin-session-url.js +2 -2
  84. package/dist/worker-row-verdict.d.ts +11 -0
  85. package/dist/worker-row-verdict.js +58 -2
  86. package/dist/workers-rows.js +38 -10
  87. package/dist/zod-unwrap.d.ts +6 -0
  88. package/dist/zod-unwrap.js +14 -7
  89. package/docs/API-SURFACE.md +100 -5
  90. package/docs/CONSUMER-INTEGRATION.md +250 -2
  91. package/llms-full.txt +26 -4
  92. package/llms.txt +11 -4
  93. package/package.json +2 -2
  94. package/dist/definition-copy.d.ts +0 -23
  95. package/dist/definition-copy.js +0 -52
@@ -17,7 +17,9 @@
17
17
  // its entry collections. zod is this package's one runtime dependency, so this costs nothing new.
18
18
  import { z } from "zod";
19
19
  import { MODEL_ENTRY_REFUSAL_CODES, ModelRefusalsWire } from "./fleet.js";
20
- import { isPlainJsonObject } from "./plain-json-object.js";
20
+ import { UNINSPECTABLE, dataDescriptor, emptyDocument, guardedSafeParse, inspect, isAccessorDescriptor, isolatedObject, ownEntriesOf, ownRow, ownValueOf } from "./own-key-read.js";
21
+ import { ownEnabledFlag } from "./catalog-manages.js";
22
+ import { isJsonArray, isPlainJsonObject, jsonShapeOf } from "./plain-json-object.js";
21
23
  import { carriesUserinfo, secretScanForms } from "./secret-scan.js"; // package-private normalization (both scanners read it) + the userinfo judgement (not in the barrel)
22
24
  import { sha256Hex } from "./sha256.js";
23
25
  import { unwrapSchema } from "./zod-unwrap.js"; // package-private: the ONE reading of zod's wrapper layers (salvage + the one-time migrations share it)
@@ -103,12 +105,12 @@ function boundIssueIdentifiers(rec) {
103
105
  changed.set(v, out);
104
106
  return out;
105
107
  };
106
- if (Array.isArray(rec.path))
108
+ if (isJsonArray(rec.path))
107
109
  rec.path = rec.path.map(bound);
108
- if (Array.isArray(rec.keys))
110
+ if (isJsonArray(rec.keys))
109
111
  rec.keys = rec.keys.map(bound);
110
112
  const params = rec.params;
111
- if (params !== undefined && Array.isArray(params.template) && Array.isArray(params.operands)) {
113
+ if (params !== undefined && isJsonArray(params.template) && isJsonArray(params.operands)) {
112
114
  const quoted = { template: params.template, operands: params.operands.map((o) => bound(o)) };
113
115
  rec.params = { ...params, ...quoted };
114
116
  rec.message = renderQuotedSentence(quoted);
@@ -124,14 +126,31 @@ function sanitizeIssueValue(v, depth) {
124
126
  return redactSecrets(v);
125
127
  if (typeof v === "symbol")
126
128
  return redactSecrets(String(v));
127
- if (v === null || typeof v !== "object")
128
- return v;
129
- if (depth > SANITIZE_MAX_DEPTH)
130
- return "[redacted-secret]";
131
- if (Array.isArray(v))
132
- return v.map((el) => sanitizeIssueValue(el, depth + 1));
133
- sanitizeIssueFields(v, depth);
134
- return v;
129
+ const shape = jsonShapeOf(v);
130
+ switch (shape) {
131
+ case "null":
132
+ case "primitive":
133
+ return v;
134
+ case "uninspectable":
135
+ return "[redacted-secret]"; // a value the walk cannot read is withheld, like one the depth bound does not reach
136
+ case "array": {
137
+ if (depth > SANITIZE_MAX_DEPTH)
138
+ return "[redacted-secret]";
139
+ const items = inspect(() => v.map((el) => sanitizeIssueValue(el, depth + 1))); // a member that cannot be read (a getter / trap that throws) ⇒ the whole value is withheld
140
+ return items.ok ? items.value : "[redacted-secret]";
141
+ }
142
+ case "plain":
143
+ case "non_plain_object": {
144
+ if (depth > SANITIZE_MAX_DEPTH)
145
+ return "[redacted-secret]";
146
+ const walked = inspect(() => sanitizeIssueFields(v, depth)); // same: a member read or write that throws ⇒ the whole value is withheld
147
+ return walked.ok ? v : "[redacted-secret]";
148
+ }
149
+ default: {
150
+ const unhandled = shape;
151
+ throw new Error(`sanitizeIssueValue: unhandled shape ${String(unhandled)}`);
152
+ }
153
+ }
135
154
  }
136
155
  export const ROLE_RANK = { viewer: 0, editor: 1, publisher: 2, admin: 3 };
137
156
  export const USER_ROLES = ["viewer", "editor", "publisher", "admin"];
@@ -181,7 +200,7 @@ export const DOMAIN_WRITE_ROLE = {
181
200
  };
182
201
  /** The minimum role required to write `domain` (the raised gate for skills/mcp, else editor). */
183
202
  export function domainWriteRole(domain) {
184
- return DOMAIN_WRITE_ROLE[domain] ?? DEFAULT_DOMAIN_WRITE_ROLE;
203
+ return ownRow(DOMAIN_WRITE_ROLE, domain) ?? DEFAULT_DOMAIN_WRITE_ROLE;
185
204
  }
186
205
  /** The empty/defaults effective config at version 0 — what publish mode serves before the first publish.
187
206
  * Seeds the epoch (`new Date(0)`) so it's deterministic (the wall-clock default was the original store.ts
@@ -230,17 +249,20 @@ export function parseDomain(domain, raw) {
230
249
  throw new Error(`"${outgoingSegment(String(domain))}" is not a config domain`);
231
250
  // ONLY true absence (undefined) defaults; an explicit `null` (e.g. a `config.d/<d>.json` containing JSON
232
251
  // `null`) is corrupt input → let zod reject it ("expected object, received null"), not silently default. R4.
233
- let r;
234
- try {
235
- r = DOMAIN_SCHEMAS[domain].safeParse(raw === undefined ? {} : raw);
236
- }
237
- catch {
238
- // a hostile getter/Proxy trap threw during traversal — its message may carry a secret; never surface it. R13.
239
- throw new Error(`invalid "${domain}" config (a value could not be read during validation)`);
240
- }
241
- if (r.success)
242
- return r.data;
243
- throw sanitizeZodError(r.error); // recursive redaction (message / every issue field / nested issues) — no secret echo. R13/R14.
252
+ return strictParse(DOMAIN_SCHEMAS[domain], domain, raw === undefined ? emptyDocument() : raw);
253
+ }
254
+ /**
255
+ * 一次**整份严判**(域文档与 wire 上的 skills 清单共用这一只 —— 清单的读法与非门控域同一臂,见 `readSkillsManifest`):过 ⇒ 数据;
256
+ * 不过 ⇒ 抛脱敏后的 ZodError(逐字段递归脱敏,不回显秘密;R13/R14);校验过程抛(含首读 `error` 时抛,见 {@link guardedSafeParse})⇒ 抛 {@link unreadableDomainError}
257
+ * (抛出值的文案可能带秘密,从不转述;R13)。
258
+ */
259
+ function strictParse(schema, label, doc) {
260
+ const judged = guardedSafeParse(() => schema.safeParse(doc), sanitizeZodError);
261
+ if (!judged.ok)
262
+ throw unreadableDomainError(label);
263
+ if (judged.value.success)
264
+ return judged.value.data;
265
+ throw judged.value.error;
244
266
  }
245
267
  /** 一个**逐字出门**的字符串段的样子 —— 一条判据,不是一串特例:**逐字写出去之后读回来只能是它自己**。
246
268
  * 所以四类字符串段必须改走引号形:空串、含 `.`(段分隔符)、含 `"`(引号形自身)或控制字符的,以及
@@ -341,22 +363,23 @@ export function withheldNameToken(name) {
341
363
  return `withheld:${sha256Hex(`${WITHHELD_NAME_TOKEN_DOMAIN}${JSON.stringify(name)}`).slice(0, 12)}`;
342
364
  }
343
365
  /** 一条被丢条目原文的身份({@link DroppedEntryIdentity} 的**唯一**读法)。只看纯 JSON 对象的**自有数据
344
- * 属性**;任何反射/陷阱抛错 ⇒ 当作读不出(身份是诊断面的附件,绝不许把抢救拖下水)。 */
366
+ * 属性**;任何反射/陷阱抛错 ⇒ 当作读不出(身份是诊断面的附件,绝不许把抢救拖下水)。`enabled` 读的是「显式停用」的那一只
367
+ * ({@link ownEnabledFlag},`./catalog-manages`):目录管不管模型面的原文判官与这里同一份读法。 */
345
368
  function readEntryIdentity(entry) {
346
369
  try {
347
370
  if (!isPlainJsonObject(entry))
348
371
  return undefined;
349
372
  const own = (k) => {
350
373
  const d = Object.getOwnPropertyDescriptor(entry, k);
351
- return d !== undefined && Object.hasOwn(d, "value") ? d.value : undefined; // 描述符本身也只读自有键
374
+ return d !== undefined && !isAccessorDescriptor(d) ? d.value : undefined; // 访问器不调(描述符的字段只认自有的,见内核)
352
375
  };
353
376
  const name = own("name");
354
377
  const id = own("id");
355
- const enabled = own("enabled");
378
+ const enabled = ownEnabledFlag(entry); // 形上面已判过一次(`isPlainJsonObject`):不再经判形的入口问第二次
356
379
  const identity = {
357
380
  ...(typeof name === "string" ? (isDisclosableIdentity(name) ? { name } : { nameWithheld: true, token: withheldNameToken(name) }) : {}),
358
381
  ...(typeof id === "string" ? (isDisclosableIdentity(id) ? { id } : { idWithheld: true }) : {}),
359
- ...(typeof enabled === "boolean" ? { enabled } : {}),
382
+ ...(enabled === undefined ? {} : { enabled }),
360
383
  };
361
384
  return Object.keys(identity).length > 0 ? identity : undefined;
362
385
  }
@@ -385,17 +408,18 @@ function droppedEntry(path, verdict, entry) {
385
408
  }
386
409
  /** 一次「什么都没丢」的抢救的位移表(冻结的共享空表 —— {@link rebaseWarningPaths} 对它恒等)。 */
387
410
  const NO_ENTRIES_DROPPED = Object.freeze({});
411
+ /** 一个域的校验过程抛了(恶意 getter / Proxy 陷阱 / 校验器自己对怪形值做类型转换)⇒ 给调用方的那一句中性话:不带抛出值的文案(可能带 secret),类是 `TypeError`(调用方给的值读不出是调用方的错)。 */
412
+ function unreadableDomainError(domain) {
413
+ return new TypeError(`invalid "${domain}" config (a value could not be read during validation)`);
414
+ }
388
415
  /** 一条条目的 parse —— 坏就返回它的判决(脱敏错误 + 闭集词)。`rejectedAs` = 调用点在集合里的位置赋予「schema 没过」的那个词
389
416
  * (条目 / Record 值 ⇒ `entry-schema-invalid`;Record 键 ⇒ `entry-key-invalid`);判的过程抛了则恒为 `entry-unreadable`,与位置无关。
390
417
  * 恶意 getter/Proxy 在遍历中抛错时**绝不**外泄其文案(可能带 secret,与 parseDomain 的 R13 同纪律),只留一句中性话。 */
391
418
  function judgeEntry(schema, value, rejectedAs) {
392
- try {
393
- const r = schema.safeParse(value);
394
- return r.success ? undefined : { error: sanitizeZodError(r.error), reasonCode: rejectedAs };
395
- }
396
- catch {
397
- return { error: new Error("entry could not be read during validation"), reasonCode: "entry-unreadable" };
398
- }
419
+ const judged = guardedSafeParse(() => schema.safeParse(value), sanitizeZodError); // 脱敏也在守护里:issue 读不完(`params` 里会抛的 getter)⇒ 这一条读不出(v14.6.0 同)
420
+ if (!judged.ok)
421
+ return { error: new TypeError("entry could not be read during validation"), reasonCode: "entry-unreadable" }; // 同一原因(校验时值读不出)一种错类:与 `domain-defaulted` 告警的 error 同为 TypeError
422
+ return judged.value.success ? undefined : { error: judged.value.error, reasonCode: rejectedAs };
399
423
  }
400
424
  /**
401
425
  * 从**schema 自身**派生条目集合(数组字段 / Record 字段),逐条判,坏的丢掉并逐条记账。
@@ -441,12 +465,8 @@ export function salvageEntries(schema, raw) {
441
465
  /** 一层抢救,抢不动就当没抢救(反射 / 访问器抛错:绝不外泄其文案)。最外层与每一条条目体内走的是**同一只** —— 一条条目体内抢不动,
442
466
  * 只是这一条不下钻、照原样去判,不连坐它所在的那一层。 */
443
467
  function salvageLayer(schema, raw) {
444
- try {
445
- return salvageLayerUnsafe(schema, raw);
446
- }
447
- catch {
448
- return { raw, dropped: [], shifts: [] };
449
- }
468
+ const layer = inspect(() => salvageLayerUnsafe(schema, raw));
469
+ return layer.ok ? layer.value : { raw, dropped: [], shifts: [] };
450
470
  }
451
471
  /** 集合字段的值 → 它的位置表;值的形与 schema 对不上(schema 说数组、文档给对象)⇒ `undefined`(不抢救,交给整域档位)。
452
472
  * 数组走 `forEach`(不是手写 `for (i < value.length)`):它在**进入时**就钉死了长度,而重读 `value.length` 会跟着一个在校验期间
@@ -454,7 +474,7 @@ function salvageLayer(schema, raw) {
454
474
  * 位置表在任何判定之前就收齐了,之后数组再怎么长都与本次抢救无关。`forEach` 跳过空洞,与此前的 `filter` 同。 */
455
475
  function collectionSlots(value, isArray) {
456
476
  if (isArray) {
457
- if (!Array.isArray(value))
477
+ if (!isJsonArray(value))
458
478
  return undefined;
459
479
  const slots = [];
460
480
  value.forEach((entry, i) => void slots.push([i, entry]));
@@ -478,7 +498,10 @@ function salvageLayerUnsafe(schema, raw) {
478
498
  const isArray = collection instanceof z.ZodArray;
479
499
  if (!isArray && !(collection instanceof z.ZodRecord))
480
500
  continue; // 不是条目集合:连读都不读(少触发一次访问器)
481
- const slots = collectionSlots(raw[field], isArray);
501
+ const held = ownValueOf(raw, field); // the field is read from the document's OWN keys (a polluted `Object.prototype.<field>` is not a collection the document holds); unreadable ⇒ nothing to salvage here
502
+ if (!held.ok)
503
+ return untouched;
504
+ const slots = collectionSlots(held.value, isArray);
482
505
  if (slots === undefined)
483
506
  continue;
484
507
  const element = (collection instanceof z.ZodArray ? collection.element : collection.valueType);
@@ -729,13 +752,13 @@ export const EXTENSION_NAMESPACES = {
729
752
  * 本身——键名里可以含 `.`(`semaX.foo` 是一个键不是两层),段形让这件事不必再靠文法猜;前缀契约就是前缀
730
753
  * 契约(`semaGPT_5` / `semaX-v2` 都是成员,后缀不设未声明的文法)。 */
731
754
  export function isReservedExtensionKey(domain, parent, key) {
732
- const rules = EXTENSION_NAMESPACES[domain];
755
+ const rules = ownRow(EXTENSION_NAMESPACES, domain);
733
756
  return rules !== undefined && rules.some((r) => r.at.length === parent.length && r.at.every((seg, i) => (seg === ANY_INDEX ? typeof parent[i] === "number" : seg === parent[i])) && r.key.test(key));
734
757
  }
735
758
  function collectDropped(domain, raw, parsed, prefix, depth, acc) {
736
759
  if (depth > DROP_SCAN_MAX_DEPTH)
737
760
  return;
738
- if (Array.isArray(raw) && Array.isArray(parsed)) {
761
+ if (isJsonArray(raw) && isJsonArray(parsed)) {
739
762
  // 长度不同 = 整条元素被丢(`entry-dropped` 有自己的警告),不在本判据内,别造重复噪声。
740
763
  if (raw.length !== parsed.length)
741
764
  return;
@@ -818,13 +841,18 @@ const OPEN_WORLD_KNOWN_KEYS = {
818
841
  * 子树)不再下探,那本就是消费方自定义的内容,逐层猜没有意义。
819
842
  */
820
843
  function carriedUnknownKeys(domain, parsed) {
821
- const table = OPEN_WORLD_KNOWN_KEYS[domain];
844
+ const table = ownRow(OPEN_WORLD_KNOWN_KEYS, domain);
822
845
  const acc = { keys: [], total: 0 };
823
846
  if (table === undefined)
824
847
  return acc;
825
848
  for (const [at, known] of table) {
826
849
  // 段形下探:`[]` 落在文档根上,所以「顶层 vs 嵌套」不再是两条分支(规则少一条)。
827
- const node = at.reduce((cur, seg) => (isPlainJsonObject(cur) ? cur[String(seg)] : undefined), parsed);
850
+ const node = at.reduce((cur, seg) => {
851
+ if (!isPlainJsonObject(cur))
852
+ return undefined;
853
+ const read = ownValueOf(cur, String(seg));
854
+ return read.ok ? read.value : undefined;
855
+ }, parsed);
828
856
  if (!isPlainJsonObject(node))
829
857
  continue;
830
858
  for (const k of Object.keys(node)) {
@@ -892,16 +920,7 @@ function readDomainDocument(domain, raw, onWarning, whenUnreadable) {
892
920
  function parseSalvaged(domain, raw, unreadable, onWarning) {
893
921
  if (DOMAIN_ENTRY_SALVAGE[domain] !== "salvage")
894
922
  throw unreadable;
895
- const salvage = salvageEntries(DOMAIN_SCHEMAS[domain], raw);
896
- if (salvage.dropped.length === 0)
897
- throw unreadable;
898
- let parsed;
899
- try {
900
- parsed = parseDomain(domain, salvage.raw);
901
- }
902
- catch {
903
- throw unreadable; // 条目之外还有毛病(重名 / 悬空 ref / 域级判据)—— 丢条目救不了它
904
- }
923
+ const { parsed, salvage } = salvageThenParse(DOMAIN_SCHEMAS[domain], raw, (doc) => parseDomain(domain, doc), unreadable);
905
924
  if (onWarning) {
906
925
  // 坐标律(S-323b):一条告警用「铸它的那一层所见文档」的坐标表达;哪一层压缩了文档,哪一层就把从它下方上来的告警回写一次。
907
926
  // `entry-dropped` 是本层(salvage)铸的,用的就是本层入参那份文档的坐标 ⇒ 原样出门;未知键扫描是对本层**压缩后**文档的诊断 ⇒ 过本层的位移表。
@@ -911,6 +930,23 @@ function parseSalvaged(domain, raw, unreadable, onWarning) {
911
930
  }
912
931
  return parsed;
913
932
  }
933
+ /**
934
+ * 逐条抢救的**那一步**(域面 {@link parseSalvaged} 与 wire 面 skills 清单 `readSkillsManifest` 共用 —— 一条律,两个调用点):整份严判已经失败
935
+ * (校验不过,或校验过程抛 —— 同一条臂:{@link salvageEntries} 逐条判,抛的那一条记 `entry-unreadable` 丢它自己)之后,丢掉坏条目再严判一次。
936
+ * 什么都没丢 / 丢了仍读不进(条目之外还有毛病:重名 / 悬空 ref / 域级判据;或抢救后的文档在重判时读不出)⇒ 抛 `unreadable`(第一次严判的原错)——
937
+ * 一个文档一个结论,没救活就不留「丢了某条」的半路叙述。
938
+ */
939
+ function salvageThenParse(schema, raw, parse, unreadable) {
940
+ const salvage = salvageEntries(schema, raw);
941
+ if (salvage.dropped.length === 0)
942
+ throw unreadable;
943
+ try {
944
+ return { parsed: parse(salvage.raw), salvage };
945
+ }
946
+ catch {
947
+ throw unreadable;
948
+ }
949
+ }
914
950
  /** 未知键两条告警(剥掉的 / 承运的)—— `raw` 是这次真被 parse 的那份文档,`emit` 决定坐标出门前要不要回写。 */
915
951
  function announceUnknownKeys(domain, raw, parsed, emit) {
916
952
  const dropped = droppedKeys(domain, raw, parsed);
@@ -1098,9 +1134,31 @@ export function buildEffective(version, getRaw, updatedAt, onWarning) {
1098
1134
  * and so does any key no domain names(the 0.6-era `teams` key included — not a domain since 0.7, and no engine reads it). Throws ONLY on garbage no caller can partially trust: a non-object payload, or a missing/
1099
1135
  * non-finite `version` — that is not "a bad domain", it is not an effective config at all. */
1100
1136
  export function readEffectiveWire(raw, onWarning) {
1101
- if (!isPlainJsonObject(raw))
1102
- throw new Error("effective wire payload is not a JSON object");
1103
- const r = raw;
1137
+ // 判形只问座位:读不出(撤销的 Proxy / 陷阱抛)是「读不出」—— 与下面读键读不出同一类同一句,不是「不是对象」;不是对象(null / 数组 / 原始值 / 类实例)才是既有的那句
1138
+ const rawShape = jsonShapeOf(raw);
1139
+ switch (rawShape) {
1140
+ case "plain":
1141
+ break;
1142
+ case "uninspectable":
1143
+ throw new TypeError(`effective wire payload could not be read — ${UNINSPECTABLE}`);
1144
+ case "non_plain_object":
1145
+ case "array":
1146
+ case "null":
1147
+ case "primitive":
1148
+ throw new Error("effective wire payload is not a JSON object");
1149
+ default: {
1150
+ const unhandled = rawShape;
1151
+ throw new Error(`readEffectiveWire: unhandled shape ${String(unhandled)}`);
1152
+ }
1153
+ }
1154
+ // 载荷只读一次,读成它自有的键值快照(隔离原型 `COPY_PROTOTYPE` 的袋):信封键(`version` / `updatedAt`)、域键、`skills` / `prompts` 与未知键都从快照里取 ——
1155
+ // 键只靠继承可见(`Object.prototype.models` 被污染成数据值)不算载荷里写着的;读不出(`get` 陷阱 / 会抛的 getter)⇒ 抛本函数自己的那一句,不是引擎的错
1156
+ const entries = ownEntriesOf(raw); // 自有串键里有不可枚举的 ⇒ 整份读不出(不当缺席、不静默丢)
1157
+ if (!entries.ok)
1158
+ throw new TypeError(`effective wire payload could not be read — ${entries.sentence}`);
1159
+ const r = isolatedObject(); // 载荷的袋:与所有副本同一只原型
1160
+ for (const [k, v] of entries.value)
1161
+ Object.defineProperty(r, k, dataDescriptor(v));
1104
1162
  if (typeof r.version !== "number" || !Number.isFinite(r.version))
1105
1163
  throw new Error("effective wire payload has no numeric version");
1106
1164
  const updatedAt = typeof r.updatedAt === "string" ? r.updatedAt : "";
@@ -1112,26 +1170,12 @@ export function readEffectiveWire(raw, onWarning) {
1112
1170
  // downstream as "the center withdrew its layer" — the consumer's judge refuses it loudly instead. verbatim, by presence(见 return)。
1113
1171
  const built = buildEffective(r.version, (d) => (d === "skills" || d === "prompts" ? undefined : r[d]), updatedAt, onWarning);
1114
1172
  // retired 域名不是 open-world 的未知键:墓碑已在 buildEffective 里判过(带事实 ⇒ 抛),剩下的 `{}` 没有可承运的东西。
1115
- let skills;
1116
- const manifest = SkillsManifestSchema.safeParse(r.skills ?? {});
1117
- if (manifest.success) {
1118
- skills = manifest.data;
1119
- }
1120
- else {
1121
- // 逐条容错律的 wire 腿:manifest 走的是另一张 schema,但**同一个**抢救点 —— 一条坏条目只丢它自己,
1122
- // 整张 skill 清单不再因此蒸发(零幸存者仍整份退默认,见 salvageEntries 头注)。
1123
- const salvage = salvageEntries(SkillsManifestSchema, r.skills);
1124
- const retry = salvage.dropped.length > 0 ? SkillsManifestSchema.safeParse(salvage.raw) : undefined;
1125
- if (retry?.success) {
1126
- for (const d of salvage.dropped)
1127
- onWarning?.(entryDroppedWarning("skills", d));
1128
- skills = retry.data;
1129
- }
1130
- else {
1131
- onWarning?.({ domain: "skills", kind: "domain-defaulted", error: sanitizeZodError(manifest.error) });
1132
- skills = { skills: [] };
1133
- }
1134
- }
1173
+ const manifest = readSkillsManifest(r.skills);
1174
+ // 告警在判完之后、读法之外发:`onWarning` 自己抛的错不会被当成「清单读不进」
1175
+ if (onWarning)
1176
+ for (const w of manifest.warnings)
1177
+ onWarning(w);
1178
+ const skills = manifest.skills;
1135
1179
  // Open-world passthrough: keys that are neither validated domains nor the envelope fields ride verbatim.
1136
1180
  // `hasOwnProperty.call(built, k)`,**不是** `k in built`(S-332):`in` 会一路问到 `Object.prototype`,
1137
1181
  // 于是 `constructor` / `toString` / `hasOwnProperty` / `__proto__` 这些**原型链上的名字**作未知顶层键时
@@ -1143,7 +1187,29 @@ export function readEffectiveWire(raw, onWarning) {
1143
1187
  // prompts:剥掉 buildEffective 恒物化的 `{}` 底座,按 wire 在场性 verbatim 挂回(#100:
1144
1188
  // 缺席进缺席出——发布闸关是常态形,物化会让消费端「缺席」分支永不可达)。
1145
1189
  const { prompts: _promptsMaterialized, ...builtSansPrompts } = built;
1146
- return { ...passthrough, ...builtSansPrompts, skills, ...("prompts" in r ? { prompts: r.prompts } : {}) };
1190
+ return { ...passthrough, ...builtSansPrompts, skills, ...(Object.hasOwn(r, "prompts") ? { prompts: r.prompts } : {}) };
1191
+ }
1192
+ /**
1193
+ * wire 上 skills 清单的读法 —— **与非门控域同一臂**,只是判的是清单 schema({@link SkillsManifestSchema}):整份严判({@link strictParse})→ 读不进
1194
+ * (校验不过,或校验器自己抛 —— 合法 JSON 里的怪形值、嵌入方对象的 getter / 陷阱 —— 同一条臂)⇒ 逐条抢救({@link salvageThenParse}:坏条目丢它自己 + `entry-dropped`,
1195
+ * 其余合法条目保留;零幸存者不凭空造空集合)→ 仍读不进 ⇒ 整份退默认(空清单)+ `domain-defaulted`(`error` = 第一次严判的原错:脱敏的 ZodError,或包自己那一句的 TypeError)。
1196
+ * 缺席 ⇒ 按包自己造的空文档判(`COPY_PROTOTYPE`,读不到污染值)。告警交回给调用方发。
1197
+ */
1198
+ function readSkillsManifest(raw) {
1199
+ const parse = (doc) => strictParse(SkillsManifestSchema, "skills", doc);
1200
+ try {
1201
+ return { skills: parse(raw ?? emptyDocument()), warnings: [] };
1202
+ }
1203
+ catch (unreadable) {
1204
+ try {
1205
+ const { parsed, salvage } = salvageThenParse(SkillsManifestSchema, raw, parse, unreadable);
1206
+ return { skills: parsed, warnings: salvage.dropped.map((d) => entryDroppedWarning("skills", d)) };
1207
+ }
1208
+ catch {
1209
+ // 救不回 ⇒ 告警带第一次严判的原错(salvageThenParse 只重抛它;salvageEntries 是总的)—— 在结构上就是它,不靠「被捕获的恰好是它」
1210
+ return { skills: { skills: [] }, warnings: [{ domain: "skills", kind: "domain-defaulted", error: unreadable }] };
1211
+ }
1212
+ }
1147
1213
  }
1148
1214
  /** 档位组→引擎单表的唯一解析点(0.9.0)。返回 ACTIVE 档位组的绑定表(剔除未绑档),即 core
1149
1215
  * `RunnerDeps.tiers` 要吃的那张单表——组切换=web-admin 换这张表下发,零新引擎机制。无 active 组/未命中/
@@ -0,0 +1,106 @@
1
+ /**
2
+ * 配置根上的**根级文档**(纯、零 I/O、浏览器安全;读写函数在 `./node`,见 `./config-root-node`)。
3
+ *
4
+ * ```
5
+ * <根>/
6
+ * config.d/… 便携域文档(文件仓库只扫这一个目录)
7
+ * config.env-models.json env 模型表被目录顶替的部署决定 {"envModels":"replaced"|"kept"}
8
+ * config.migration.SS-M11.json 一次性迁移 SS-M11 的运行记录 {"migration":"SS-M11"}(按 id 一份)
9
+ * .env 秘密值(不归本包读)
10
+ * ```
11
+ *
12
+ * ## 律
13
+ * · **根归客户端(TOC)所有**,引擎在根上只写这两份文档;两份都在 `config.d/` 之外 —— 已核的各版文件仓库都只扫 `<根>/config.d`(抽样,不证全称),
14
+ * 被检视的配置读者不把这两份当域文档、不因它们在场拒配置,所以写者不探引擎版本。
15
+ * 离线预置(引擎在本根起服之前铺好:镜像 / ConfigMap / 还原)时两份文档与域文档可按任意顺序写;运行时写序是定的:写者先写决定、再写 `config.d/models.json`
16
+ * (都在它对 models.json 的那把锁里),迁移先建运行记录、再铸决定。
17
+ * · **一份读法、一张闭集答案表**:决定只有 {@link EnvModelsDecision} 这一种答案(从字节读出的带同一次读的 `digest`,读不到字节的带 `errno`),
18
+ * 读这份决定的三方 —— 引擎(本地配置腿强制这道闸之后;今天引擎还不读它)、一次性迁移 SS-M11、写者的计划助手 —— 吃同一个答案;它的身份(告警去重键、本地 etag 的贡献)只有一种拼法 {@link envModelsDecisionTag}。
19
+ * · **运行记录按 id 一份**:「迁移 X 跑过」⇔ `<根>/config.migration.<X>.json` 在场({@link MigrationMarker});没有聚合文件、没有追加写;
20
+ * 读不懂的记录按「跑过」处置(fail-closed)。记录由迁移以「不存在才建」写:同一个 id 只有一个进程建得成 —— 它是那条迁移在本根上唯一的执行者。
21
+ * · 两份文档的形都是 strict 的:多一个键(含自有 `__proto__`)、拼错的词都读成 `bad_shape`,不剥、不猜;digest 对读到的**字节**求。名字只在这里定一次(引擎的目录信任判官取 {@link CONFIG_ROOT_DOCUMENTS})。
22
+ */
23
+ import type { OneTimeMigrationId } from "./migration-receipt.js";
24
+ /** 配置根上的 env 模型表决定文档的名字(文件 `<根>/config.env-models.json`)。 */
25
+ export declare const ENV_MODELS_DOCUMENT: "config.env-models";
26
+ /** 运行记录文档名的前缀(包内:名字 = 前缀 + 迁移 id,文件 = 名字 + `.json`)。 */
27
+ declare const MIGRATION_MARKER_PREFIX: "config.migration.";
28
+ /** 一条一次性迁移的运行记录文档名(包内)。 */
29
+ export declare const migrationMarkerDocument: <I extends OneTimeMigrationId>(id: I) => `${typeof MIGRATION_MARKER_PREFIX}${I}`;
30
+ /**
31
+ * 根级文档名单(闭集、序固定;文件 = 名字 + `.json`,与 `config.d` 文档同一拼法):决定文档,与今天唯一会写运行记录的迁移 SS-M11 的那一份。
32
+ * 引擎对配置根的目录信任判官、客户端的持久文件登记都取这一张,不手抄。判法与 `config.d` 里的文档**同一条律**(属主可信、路径上非链接组件不得组 / 他人可写、
33
+ * 常规文件、有大小上界)—— 一条律、两张名单(`config.d` 读回面一张,本张一张);`config.d` 不在时也照判。
34
+ * `<根>/.env` **不在**这张名单:引擎今天的目录信任判官只判 `config.d` 与它的读回面,不判 `.env`(秘密值文件,本包的文件仓库不读它);本名单只登记引擎会读的根级文档。
35
+ */
36
+ export declare const CONFIG_ROOT_DOCUMENTS: readonly ["config.env-models", "config.migration.SS-M11"];
37
+ export type ConfigRootDocument = (typeof CONFIG_ROOT_DOCUMENTS)[number];
38
+ /** 有运行记录的迁移 id(由 {@link CONFIG_ROOT_DOCUMENTS} 派生:名单里有它的记录文档 ⇔ 它写记录)。 */
39
+ export type MarkedMigrationId = {
40
+ [I in OneTimeMigrationId]: `${typeof MIGRATION_MARKER_PREFIX}${I}` extends ConfigRootDocument ? I : never;
41
+ }[OneTimeMigrationId];
42
+ /** 包内:`id` 是写运行记录的迁移吗(读运行记录之前判:别的 id 没有记录可读,更不许拿它拼路径)。 */
43
+ export declare const isMarkedMigrationId: (id: unknown) => id is MarkedMigrationId;
44
+ /** 决定文档里能写的两个词(闭集):`replaced` = 目录管模型面时顶替 env 模型表;`kept` = 不顶替(与缺席同义,运行记录在场之后)。 */
45
+ export declare const ENV_MODELS_DECISIONS: readonly ["replaced", "kept"];
46
+ export type EnvModelsDecisionWord = (typeof ENV_MODELS_DECISIONS)[number];
47
+ /** 包内:是不是决定词(闭集,判法只此一只;写函数与读法同调)。 */
48
+ export declare const isDecisionWord: (v: unknown) => v is EnvModelsDecisionWord;
49
+ /** 根级文档读不懂的三种原因(闭集):`bad_json` = 空 / 纯空白 / 不是 JSON;`bad_shape` = 是 JSON 但不是这份文档的形;`io` = 字节读不出(权限 —— 可信属主的 0000 文件读恒 `EACCES`,是持久的 —— 不是常规文件、悬空链接、瞬时读错)。 */
50
+ export declare const ROOT_DOCUMENT_UNREADABLE_REASONS: readonly ["bad_json", "bad_shape", "io"];
51
+ export type RootDocumentUnreadableReason = (typeof ROOT_DOCUMENT_UNREADABLE_REASONS)[number];
52
+ /**
53
+ * 决定文档的读数(闭集;三方穷举 `switch`)。`absent` 只在文件不存在时答 —— 读不出的字节是 `unreadable`,不是缺席。
54
+ * 有字节的四臂(`replaced` / `kept` / `unreadable{bad_json|bad_shape}`)带 `digest` = **同一次读**得到的字节的 SHA-256 前 16 位十六进制;
55
+ * 读不到字节的 `unreadable{io}` 带 `errno`(错误上没有 errno ⇒ `uncoded`)。`{` 改成 `[` 这种「答案同为 bad_json、字节不同」的改动 digest 也变。digest 不可逆,不带内容。
56
+ */
57
+ export type EnvModelsDecision = {
58
+ readonly kind: "absent";
59
+ } | {
60
+ readonly kind: "replaced";
61
+ readonly digest: string;
62
+ } | {
63
+ readonly kind: "kept";
64
+ readonly digest: string;
65
+ } | {
66
+ readonly kind: "unreadable";
67
+ readonly reason: "bad_json" | "bad_shape";
68
+ readonly digest: string;
69
+ } | {
70
+ readonly kind: "unreadable";
71
+ readonly reason: "io";
72
+ readonly errno: string;
73
+ };
74
+ /**
75
+ * 一份决定读数的**身份**(带标签的一种拼法):`absent` / `d:<digest>`(有字节的四臂)/ `io:<errno>`。引擎读这份决定时(今天引擎还不读),「每份内容只告警一次」的去重键与本地 etag 的贡献都取它 ——
76
+ * 来自同一次读,不再开文件(那就是第二个读者)。
77
+ */
78
+ export declare function envModelsDecisionTag(decision: EnvModelsDecision): string;
79
+ /** 一份运行记录的读数(闭集):`present` = 那条迁移跑过(或正被它的执行者跑);读不懂的按「跑过」处置(fail-closed)。 */
80
+ export type MigrationMarker = {
81
+ readonly kind: "absent";
82
+ } | {
83
+ readonly kind: "present";
84
+ } | {
85
+ readonly kind: "unreadable";
86
+ readonly reason: RootDocumentUnreadableReason;
87
+ };
88
+ /** 包内:决定文档的**字节读法**(唯一一份;`./config-root-node` 的读者在拿到字节之后调它)—— 字节 → 答案(带这些字节的 digest)。 */
89
+ export declare function readEnvModelsDecisionBytes(bytes: Uint8Array): Exclude<EnvModelsDecision, {
90
+ readonly kind: "absent";
91
+ } | {
92
+ readonly reason: "io";
93
+ }>;
94
+ /** 包内:字节没读到时的决定读数(errno 的拼法只在本文件)。 */
95
+ export declare function ioDecision(errno: string | undefined): Extract<EnvModelsDecision, {
96
+ readonly reason: "io";
97
+ }>;
98
+ /** 包内:运行记录文档的值(`{"migration":<id>}`,写与读同一只形)。 */
99
+ export declare const migrationMarkerValue: (id: MarkedMigrationId) => {
100
+ readonly migration: MarkedMigrationId;
101
+ };
102
+ /** 包内:运行记录的**字节读法**(唯一一份):`{"migration":<这条记录的 id>}` strict ⇒ `present`;别的 ⇒ 读不懂(id 对不上也是坏形)。 */
103
+ export declare function readMigrationMarkerBytes(id: MarkedMigrationId, bytes: Uint8Array): MigrationMarker;
104
+ /** 包内:字节 → 文本(`config.d/models.json` 的读也经它,与上面两份同一解码)。 */
105
+ export declare const decodeRootBytes: (bytes: Uint8Array) => string;
106
+ export {};
@@ -0,0 +1,93 @@
1
+ import { isPlainJsonObject } from "./plain-json-object.js";
2
+ import { sha256Digest, toHex } from "./sha256-core.js";
3
+ /** 配置根上的 env 模型表决定文档的名字(文件 `<根>/config.env-models.json`)。 */
4
+ export const ENV_MODELS_DOCUMENT = "config.env-models";
5
+ /** 运行记录文档名的前缀(包内:名字 = 前缀 + 迁移 id,文件 = 名字 + `.json`)。 */
6
+ const MIGRATION_MARKER_PREFIX = "config.migration.";
7
+ /** 一条一次性迁移的运行记录文档名(包内)。 */
8
+ export const migrationMarkerDocument = (id) => `${MIGRATION_MARKER_PREFIX}${id}`;
9
+ /**
10
+ * 根级文档名单(闭集、序固定;文件 = 名字 + `.json`,与 `config.d` 文档同一拼法):决定文档,与今天唯一会写运行记录的迁移 SS-M11 的那一份。
11
+ * 引擎对配置根的目录信任判官、客户端的持久文件登记都取这一张,不手抄。判法与 `config.d` 里的文档**同一条律**(属主可信、路径上非链接组件不得组 / 他人可写、
12
+ * 常规文件、有大小上界)—— 一条律、两张名单(`config.d` 读回面一张,本张一张);`config.d` 不在时也照判。
13
+ * `<根>/.env` **不在**这张名单:引擎今天的目录信任判官只判 `config.d` 与它的读回面,不判 `.env`(秘密值文件,本包的文件仓库不读它);本名单只登记引擎会读的根级文档。
14
+ */
15
+ export const CONFIG_ROOT_DOCUMENTS = [ENV_MODELS_DOCUMENT, migrationMarkerDocument("SS-M11")];
16
+ /** 包内:`id` 是写运行记录的迁移吗(读运行记录之前判:别的 id 没有记录可读,更不许拿它拼路径)。 */
17
+ export const isMarkedMigrationId = (id) => typeof id === "string" && CONFIG_ROOT_DOCUMENTS.includes(`${MIGRATION_MARKER_PREFIX}${id}`);
18
+ /** 决定文档里能写的两个词(闭集):`replaced` = 目录管模型面时顶替 env 模型表;`kept` = 不顶替(与缺席同义,运行记录在场之后)。 */
19
+ export const ENV_MODELS_DECISIONS = ["replaced", "kept"];
20
+ /** 包内:是不是决定词(闭集,判法只此一只;写函数与读法同调)。 */
21
+ export const isDecisionWord = (v) => ENV_MODELS_DECISIONS.includes(v);
22
+ /** 根级文档读不懂的三种原因(闭集):`bad_json` = 空 / 纯空白 / 不是 JSON;`bad_shape` = 是 JSON 但不是这份文档的形;`io` = 字节读不出(权限 —— 可信属主的 0000 文件读恒 `EACCES`,是持久的 —— 不是常规文件、悬空链接、瞬时读错)。 */
23
+ export const ROOT_DOCUMENT_UNREADABLE_REASONS = ["bad_json", "bad_shape", "io"];
24
+ /**
25
+ * 一份决定读数的**身份**(带标签的一种拼法):`absent` / `d:<digest>`(有字节的四臂)/ `io:<errno>`。引擎读这份决定时(今天引擎还不读),「每份内容只告警一次」的去重键与本地 etag 的贡献都取它 ——
26
+ * 来自同一次读,不再开文件(那就是第二个读者)。
27
+ */
28
+ export function envModelsDecisionTag(decision) {
29
+ switch (decision.kind) {
30
+ case "absent":
31
+ return "absent";
32
+ case "replaced":
33
+ case "kept":
34
+ return `d:${decision.digest}`;
35
+ case "unreadable":
36
+ return decision.reason === "io" ? `io:${decision.errno}` : `d:${decision.digest}`;
37
+ default: {
38
+ const unhandled = decision;
39
+ throw new Error(`envModelsDecisionTag: unhandled decision ${JSON.stringify(unhandled)}`);
40
+ }
41
+ }
42
+ }
43
+ /** 同一次读得到的**字节**的 digest(见 {@link EnvModelsDecision};对字节求,不对解码后的文本 —— 非法 UTF-8 解码成 U+FFFD 之后不同的字节会同 digest)。 */
44
+ const digestOf = (bytes) => toHex(sha256Digest(bytes)).slice(0, 16);
45
+ /** 字节 → 文本(UTF-8;非法序列换 U+FFFD;BOM 留在文本里,由 {@link parseJsonText} 的 trim 去掉 —— 与 `config.d` 文档的文本读法同一结果)。 */
46
+ const textOf = (bytes) => new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes);
47
+ /**
48
+ * 一个 JSON 值若是「恰好这一个自有键」的纯对象 ⇒ 那个键的值;否则(多一个键 —— 含 `JSON.parse` 铸出的自有 `__proto__` 键 —— 少一个键、不是纯对象)⇒ `undefined`。
49
+ * 两份文档的形都是 strict 的,判在这里一处;不交给 zod 的 strict:它对自有 `__proto__` 键的处置随版本变(4.4.x 跳过它、4.6.x 拒它),本包的 zod 区间两端答案会不同。
50
+ * 文档是 `JSON.parse` 的产物,自有键都是可枚举数据属性,`Object.keys` 就是它的全部键。
51
+ */
52
+ function soleOwnValue(v, key) {
53
+ if (!isPlainJsonObject(v))
54
+ return undefined;
55
+ const keys = Object.keys(v);
56
+ return keys.length === 1 && keys[0] === key ? v[key] : undefined;
57
+ }
58
+ /** 文本 → JSON 值:空 / 纯空白 / 坏 JSON ⇒ 读不成(一份被截断的文件不是缺席)。 */
59
+ function parseJsonText(text) {
60
+ const trimmed = text.trim();
61
+ if (!trimmed)
62
+ return { ok: false };
63
+ try {
64
+ return { ok: true, value: JSON.parse(trimmed) };
65
+ }
66
+ catch {
67
+ return { ok: false };
68
+ }
69
+ }
70
+ /** 包内:决定文档的**字节读法**(唯一一份;`./config-root-node` 的读者在拿到字节之后调它)—— 字节 → 答案(带这些字节的 digest)。 */
71
+ export function readEnvModelsDecisionBytes(bytes) {
72
+ const digest = digestOf(bytes);
73
+ const json = parseJsonText(textOf(bytes));
74
+ if (!json.ok)
75
+ return { kind: "unreadable", reason: "bad_json", digest };
76
+ const word = soleOwnValue(json.value, "envModels");
77
+ return isDecisionWord(word) ? { kind: word, digest } : { kind: "unreadable", reason: "bad_shape", digest };
78
+ }
79
+ /** 包内:字节没读到时的决定读数(errno 的拼法只在本文件)。 */
80
+ export function ioDecision(errno) {
81
+ return { kind: "unreadable", reason: "io", errno: errno ?? "uncoded" };
82
+ }
83
+ /** 包内:运行记录文档的值(`{"migration":<id>}`,写与读同一只形)。 */
84
+ export const migrationMarkerValue = (id) => ({ migration: id });
85
+ /** 包内:运行记录的**字节读法**(唯一一份):`{"migration":<这条记录的 id>}` strict ⇒ `present`;别的 ⇒ 读不懂(id 对不上也是坏形)。 */
86
+ export function readMigrationMarkerBytes(id, bytes) {
87
+ const json = parseJsonText(textOf(bytes));
88
+ if (!json.ok)
89
+ return { kind: "unreadable", reason: "bad_json" };
90
+ return soleOwnValue(json.value, "migration") === id ? { kind: "present" } : { kind: "unreadable", reason: "bad_shape" };
91
+ }
92
+ /** 包内:字节 → 文本(`config.d/models.json` 的读也经它,与上面两份同一解码)。 */
93
+ export const decodeRootBytes = textOf;