@sema-agent/settings-schema 6.1.0 → 6.3.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 (39) hide show
  1. package/CHANGELOG.md +265 -0
  2. package/README.md +40 -9
  3. package/conformance/VERSION +1 -1
  4. package/conformance/cases/c41-s792-encoded-pem-identity/expected.json +166 -0
  5. package/conformance/cases/c41-s792-encoded-pem-identity/input.json +62 -0
  6. package/conformance/cases/c41-s792-encoded-pem-identity/meta.json +12 -0
  7. package/conformance/cases/c42-s792-unknown-key-secret-redacted/expected.json +120 -0
  8. package/conformance/cases/c42-s792-unknown-key-secret-redacted/input.json +25 -0
  9. package/conformance/cases/c42-s792-unknown-key-secret-redacted/meta.json +11 -0
  10. package/conformance/cases/c43-s792-form-and-deep-encoded-pem/expected.json +137 -0
  11. package/conformance/cases/c43-s792-form-and-deep-encoded-pem/input.json +44 -0
  12. package/conformance/cases/c43-s792-form-and-deep-encoded-pem/meta.json +12 -0
  13. package/conformance/cases/c44-s792-utf8-escape-glued-token/expected.json +115 -0
  14. package/conformance/cases/c44-s792-utf8-escape-glued-token/input.json +30 -0
  15. package/conformance/cases/c44-s792-utf8-escape-glued-token/meta.json +10 -0
  16. package/conformance/cases/c45-s792-identity-bounds-and-decode-depth/expected.json +185 -0
  17. package/conformance/cases/c45-s792-identity-bounds-and-decode-depth/input.json +78 -0
  18. package/conformance/cases/c45-s792-identity-bounds-and-decode-depth/meta.json +11 -0
  19. package/conformance/cases/c46-s811-host-name-url-userinfo-characterization/expected.json +88 -0
  20. package/conformance/cases/c46-s811-host-name-url-userinfo-characterization/input.json +18 -0
  21. package/conformance/cases/c46-s811-host-name-url-userinfo-characterization/meta.json +6 -0
  22. package/conformance/cases/c47-s811-roles-key-url-userinfo-characterization/expected.json +98 -0
  23. package/conformance/cases/c47-s811-roles-key-url-userinfo-characterization/input.json +26 -0
  24. package/conformance/cases/c47-s811-roles-key-url-userinfo-characterization/meta.json +6 -0
  25. package/conformance/cases/cli01-model-name-nfc-vs-nfd/expected.json +0 -8
  26. package/conformance/cases/cli01-model-name-nfc-vs-nfd/meta.json +2 -1
  27. package/conformance/cases/cli02-control-and-zero-width-chars/expected.json +0 -8
  28. package/conformance/cases/cli02-control-and-zero-width-chars/meta.json +2 -1
  29. package/dist/config-fns.d.ts +62 -14
  30. package/dist/config-fns.js +105 -30
  31. package/dist/cross-session.d.ts +8 -5
  32. package/dist/cross-session.js +8 -5
  33. package/dist/fleet.d.ts +163 -0
  34. package/dist/fleet.js +126 -5
  35. package/dist/secret-scan.d.ts +39 -0
  36. package/dist/secret-scan.js +110 -0
  37. package/dist/types.d.ts +79 -72
  38. package/dist/types.js +17 -31
  39. package/package.json +2 -2
@@ -16,6 +16,8 @@
16
16
  // zod is a VALUE import here (not just a type): {@link salvageEntries} introspects a domain schema to find
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
+ import { MODEL_ENTRY_REFUSAL_CODES, ModelRefusalsWire } from "./fleet.js";
20
+ import { carriesUserinfo, secretScanForms } from "./secret-scan.js"; // package-private display-side normalization + the userinfo judgement (not in the barrel)
19
21
  import { DOMAIN_SCHEMAS, GOVERNANCE_MIRROR_KEYS, HOSTS_ARRAY_MAX, HOST_NAME_MAX, InfraCostRates, LimitsConfig, PRIVATE_KEY_PEM_HEADER, SkillsManifestSchema, containsSecretToken } from "./types.js";
20
22
  /** Redact secret-shaped tokens from a validation MESSAGE — a zod error echoes the offending value (e.g.
21
23
  * `received 'sk-live-…'`), so a secret pasted into the WRONG field would leak via the error string. Redacts
@@ -23,20 +25,32 @@ import { DOMAIN_SCHEMAS, GOVERNANCE_MIRROR_KEYS, HOSTS_ARRAY_MAX, HOST_NAME_MAX,
23
25
  * any secret-shaped run (`%` kept in the run so a percent-encoded secret stays whole for the percent-aware
24
26
  * containsSecretToken). invariant ①: a secret VALUE never rides an error. R13/R14.
25
27
  *
26
- * **Output invariant** (S-712 verification pass N1 + codex review [high] ×2): a string holding a private-key PEM
27
- * header ANYWHERE ({@link PRIVATE_KEY_PEM_HEADER}) is withheld WHOLE, and no result trips `containsSecretToken`.
28
- * The PEM header is judged on the ORIGINAL text, before any replacement: the token pass can rewrite the header
29
- * itself away (`sk-note:-----BEGIN` is one token-charset run that starts with `sk-`, so it becomes
30
- * `[redacted-secret]` and the key body after it would ride out untouched — codex verification round). A PEM header
31
- * carries spaces, so the targeted passes can never isolate it, and the detector only sees it at the very start of
32
- * a string. Every warning face that goes through here (coordinates, error text, entry identity) inherits the same
33
- * guarantee. The validation-side detector `containsSecretToken` is deliberately NOT widened here: making it reject
34
- * a mid-string PEM would tighten every field it validates (a major). */
28
+ * **Output invariant** (S-712 verification pass N1 + codex review [high] ×2; 6.3.0): a string holding a private-key
29
+ * PEM header ANYWHERE ({@link PRIVATE_KEY_PEM_HEADER}) in ANY of its {@link secretScanForms} `forms` is withheld WHOLE, a
30
+ * string whose normalization did not finish (`unresolved`) is withheld WHOLE, and no result trips `containsSecretToken` on
31
+ * any of its forms.
32
+ * The whole-string judgement is ONE rule — both detectors run over the normalized forms: the PEM header on every form of
33
+ * the ORIGINAL text, `containsSecretToken` on every form of the targeted result; what could not be normalized does not go
34
+ * out (6.3.0, codex r1 F1: `-----BEGIN+PRIVATE+KEY` form-encoded headers and four-layer encodings both rode out).
35
+ * 6.3.0 (codex review of v6.1.0 [high]): `key=` + a percent-encoded PEM showed its header only on a decoded form
36
+ * (`-----BEGIN%20PRIVATE%20KEY` — `%20` is not in `[A-Z0-9 ]`), and the detector's anchored decode missed it behind
37
+ * the `key=` prefix, so the whole key rode out through coordinates, zod `received` / `message` and entry identity.
38
+ * The PEM header is judged BEFORE any replacement: the token pass can rewrite the header itself away
39
+ * (`sk-note:-----BEGIN` is one token-charset run that starts with `sk-`, so it becomes `[redacted-secret]` and the
40
+ * key body after it would ride out untouched — codex verification round). A PEM header carries spaces, so the
41
+ * targeted passes can never isolate it, and the detector only sees it at the very start of a string. Every
42
+ * free-text warning face that goes through here (coordinates, error text) inherits the same guarantee; the entry
43
+ * identity face additionally has to be an identifier ({@link isDisclosableIdentity}). The validation-side detector
44
+ * `containsSecretToken` is deliberately NOT widened here: making it reject a mid-string PEM would tighten every field
45
+ * it validates (a major). */
35
46
  export function redactSecrets(text) {
36
47
  const targeted = text
37
48
  .replace(/\b(bearer|basic)(\s+)\S+/gi, "$1$2[redacted-secret]")
38
49
  .replace(/[A-Za-z0-9_./:+=%-]{6,}/g, (tok) => (containsSecretToken(tok) ? "[redacted-secret]" : tok));
39
- return PRIVATE_KEY_PEM_HEADER.test(text) || containsSecretToken(targeted) ? "[redacted-secret]" : targeted;
50
+ const scan = secretScanForms(text);
51
+ const targetedForms = targeted === text ? scan.forms : secretScanForms(targeted).forms; // 没替换过(最常见)⇒ 同一组形,不再归一化一遍
52
+ const whole = scan.unresolved || scan.forms.some((form) => PRIVATE_KEY_PEM_HEADER.test(form)) || targetedForms.some((form) => containsSecretToken(form));
53
+ return whole ? "[redacted-secret]" : targeted;
40
54
  }
41
55
  /** Sanitize a ZodError IN PLACE so no surfaced error (message / `received` / nested `unionErrors`) echoes a
42
56
  * pasted secret. RECURSES into `unionErrors` (a `z.union` failure nests a ZodError per member — e.g. RoleTarget
@@ -184,10 +198,62 @@ const PLAIN_PATH_SEGMENT = /^(?!(?:0|[1-9]\d*)$)[^."\u0000-\u001f]+$/;
184
198
  export function formatConfigPath(p) {
185
199
  return p.map((seg) => (typeof seg === "number" ? String(seg) : PLAIN_PATH_SEGMENT.test(seg) ? seg : JSON.stringify(seg))).join(".");
186
200
  }
187
- /** 身份串:字符串,且 {@link redactSecrets} 原样放行 —— 与坐标段脱敏、`error` 文案脱敏({@link sanitizeZodError})
188
- * **同一只**判据:告警面上任何一处用户写的串,要么过这一只脱敏器出门,要么(可缺的身份)不出门。两只判据
189
- * 会在同一条告警里给出两种答案(对抗轮 #2 真复现:`Bearer` 后接 NBSP 或短令牌时,旧判据放行、坐标却打码)。 */
190
- const isIdentityString = (v) => typeof v === "string" && redactSecrets(v) === v;
201
+ /** 本文件铸的、带用户写的键段的告警坐标出门前的**唯一**一道(6.3.0,codex r1 F2):字符串段过 {@link outgoingSegment} —— 与 `error` 文案
202
+ * ({@link sanitizeZodError})、身份否决({@link isDisclosableIdentity})**同一只**脱敏器,不另设判据;数字段(下标)原样。
203
+ * 键段是用户写的,完全可能就是一条粘错位置的凭据:被丢 Record 条目的键、未知键告警(`unknown-keys-dropped` /
204
+ * `-carried`)的键名都一样(后者 0.19.0 起从不过脱敏器,`sk-…` 形的未知键名原样进告警与投影)。schema 已知的字段名
205
+ * 脱敏器原样放行,所以坐标里「它是哪个字段」照旧可读。经它的坐标就是出门形 —— {@link droppedKeys} /
206
+ * {@link carriedUnknownKeys} 这两只导出函数给消费方的 `keys` 同样是它的产物。`legacy-governance-lifted.keys` 不经它:那些键只来自
207
+ * 闭集 {@link GOVERNANCE_MIRROR_KEYS},按构造没有用户写的串(opus 腿 I1)。 */
208
+ function outgoingPath(p) {
209
+ return p.map((seg) => (typeof seg === "string" ? outgoingSegment(seg) : seg));
210
+ }
211
+ /** 一段用户写的串(坐标键段、`hosts-grandfathered` 被夹紧的主机名)出门前的那一道:长过 {@link DISCLOSABLE_IDENTITY_MAX}(与身份白名单同一只上界)
212
+ * ⇒ 不扫描、整段 `[redacted-secret]`(标识符不会那么长;fail-closed 且 O(1) —— opus 独立腿 M1:6.3.0 首稿让每个未知键名
213
+ * 不论多长都过脱敏器,64 个 16 KB 的键名把一次读拖到 20 s 以上);否则过 {@link redactSecrets}。 */
214
+ function outgoingSegment(seg) {
215
+ return seg.length > DISCLOSABLE_IDENTITY_MAX ? "[redacted-secret]" : redactSecrets(seg);
216
+ }
217
+ /** 可披露身份与出门坐标段的**长度上界**(一只常量,两处用:{@link DISCLOSABLE_IDENTITY_RE} 的上限、{@link outgoingSegment}
218
+ * 的「超长不扫描」界)。与 web-admin 读侧名长上限 256 对齐。 */
219
+ const DISCLOSABLE_IDENTITY_MAX = 256;
220
+ /** 可披露身份的**闭形**(6.3.0):字符集 `A-Z a-z 0-9 . _ : / @ + -`、长度 1–256(上限与 web-admin 读侧名长上限 256 对齐;`ModelEntry.name`
221
+ * 本身不设上限,超 256 字的合法名按无名处置)。身份是**标识符**不是自由文本 ——
222
+ * 允许集才是它的语义:上游模型 id 的真实形(`accounts/fireworks/models/…`、`Qwen/Qwen2.5-72B-Instruct`、
223
+ * `claude-3-5-sonnet@20240620`、`deepseek-v4-flash:free`、`o3-mini+high`)都在集内;`%` / `=` / 空白 / 引号 / 反斜杠 /
224
+ * 控制与零宽字符 / 非 ASCII 一律在集外 —— 编码过的、带前缀的、多行的、会在日志里伪造行的串,不可能是一个标识符。
225
+ * 无旗标(无 `g` ⇒ `.test` 无状态,可放心共享这一只)。判据只有 {@link isDisclosableIdentity} 一只,消费方要
226
+ * 「这个名能不能写出去」问它,不要拿本常量自拼(集内仍有密钥形,见那一只的否决项)。 */
227
+ export const DISCLOSABLE_IDENTITY_RE = new RegExp(`^[A-Za-z0-9._:/@+-]{1,${DISCLOSABLE_IDENTITY_MAX}}$`);
228
+ /** 身份串 —— 「一个用户写的身份串(条目的 `name` / `id`)能不能**原样**出门」的**唯一**判据(6.3.0 起两项合取):
229
+ * ① **形**:落在 {@link DISCLOSABLE_IDENTITY_RE} 白名单闭形里(不再是「不像密钥就可披露」—— 6.1.0 / 6.2.0 的黑名单
230
+ * 判据放行过 `key=` + 百分号编码的整把 PEM 私钥,codex 对 v6.1.0 复审 [high]);
231
+ * ② **否决**(「无密钥形」):坐标脱敏器 {@link redactSecrets} 原样放行 —— 集内照样有密钥形(`sk-…` / `ghp_…` /
232
+ * `AKIA…` / 三段 JWT 的字符全在集内),而且判它的必须是坐标段、`error` 文案({@link sanitizeZodError})用的**同一只**
233
+ * 脱敏器:两只判据会在同一条告警里给出两种答案(对抗轮 #2 真复现;集内的残例 = `@` 之后的单点 `eyJ…` 串,裸探测器
234
+ * 判阴、脱敏器会改,语料 c41 钉着);∧ 不是带 userinfo 的 URL —— 校验侧 `NON_SECRET_URL` 拒写的同一只判定
235
+ * (`carriesUserinfo`,包内私有;opus 腿 L1:`postgres://svc:pw@h` 的字符全在集内)。
236
+ * 入参是 `string`、返回 `boolean`,**刻意不是**类型谓词(`v is string`):它对一些字符串答 false,谓词形会让调用方的
237
+ * 否定分支把一个 `string` 收窄成 `never`;「是不是字符串」由调用点自己判(运行期收到非字符串照答 false、不抛)。
238
+ * 不可披露的串按**无名**处置(不带,而不是打码:打过码的串不再是身份)。
239
+ * 执法面(同一条律):被丢条目的身份({@link DroppedEntryIdentity},本文件铸);`ModelRefusalsWire` 的 `entries` 键
240
+ * (经 {@link isReportableRefusalName},S-768);引擎自己的日志 / 拒因句里要不要写出一个名字,同样问这一只。 */
241
+ export function isDisclosableIdentity(v) {
242
+ // 非字符串(JS / any 调用方)⇒ false,不抛(秘密轴 fail-closed)
243
+ return typeof v === "string" && DISCLOSABLE_IDENTITY_RE.test(v) && redactSecrets(v) === v && !carriesUserinfo(v);
244
+ }
245
+ /** S-768 —— 「一个目录条目名能不能当 {@link ModelRefusalsWire} `entries` 的键」的**唯一**判据;引擎铸键前只调这一只,
246
+ * 不过 ⇒ 按无名处置(不进键、不计数)。两条律的合取,不手抄任何一条:
247
+ * ① 这只 schema **自己载得住**这个键 —— 就地拿 schema 判(空串不过键规则;`__proto__` 这类名被 zod 的 record 静默丢掉,
248
+ * 载不住),不写名单;
249
+ * ② {@link isDisclosableIdentity}(不是白名单闭形里的标识符,或是秘密形 ⇒ 不出门)。
250
+ * 非字符串 ⇒ false。住在本文件而不是 `./fleet`:`fleet.ts` 被 `types.ts` 引,它再引本文件会成环。 */
251
+ export function isReportableRefusalName(name) {
252
+ if (!isDisclosableIdentity(name))
253
+ return false;
254
+ const probe = ModelRefusalsWire.safeParse({ entries: { [name]: MODEL_ENTRY_REFUSAL_CODES[0] } });
255
+ return probe.success && Object.hasOwn(probe.data.entries, name);
256
+ }
191
257
  /** 一条被丢条目原文的身份({@link DroppedEntryIdentity} 的**唯一**读法)。只看纯 JSON 对象的**自有数据
192
258
  * 属性**;任何反射/陷阱抛错 ⇒ 当作读不出(身份是诊断面的附件,绝不许把抢救拖下水)。 */
193
259
  function readEntryIdentity(entry) {
@@ -202,8 +268,8 @@ function readEntryIdentity(entry) {
202
268
  const id = own("id");
203
269
  const enabled = own("enabled");
204
270
  const identity = {
205
- ...(isIdentityString(name) ? { name } : {}),
206
- ...(isIdentityString(id) ? { id } : {}),
271
+ ...(typeof name === "string" && isDisclosableIdentity(name) ? { name } : {}),
272
+ ...(typeof id === "string" && isDisclosableIdentity(id) ? { id } : {}),
207
273
  ...(typeof enabled === "boolean" ? { enabled } : {}),
208
274
  };
209
275
  return Object.keys(identity).length > 0 ? identity : undefined;
@@ -253,7 +319,7 @@ function judgeEntry(schema, value) {
253
319
  * 造出一个空集合**。
254
320
  *
255
321
  * 🔴 **告警不许比它诊断的东西更危险**(复审 R1 两条,红先复现):① Record 的键是用户写的,完全可能就是
256
- * 一条粘错位置的凭据,所以路径里的键段过 {@link redactSecrets} 才出门(不变量①:secret 值永不随错误外流,
322
+ * 一条粘错位置的凭据,所以路径里的键段过 {@link redactSecrets} 才出门(6.3.0 起经 `outgoingPath` 这一道;不变量①:secret 值永不随错误外流,
257
323
  * 与 parseDomain 的 R13/R14 同一条);② 整段遍历包 try/catch —— 读字段、枚举 Record 都会触发访问器,
258
324
  * 抛出来的文案可能带 secret。抢救是**尽力而为**的:抢不动就当没抢救(返回原引用),让既有的整域档位去裁,
259
325
  * 绝不把解析拖下水、更不回显它的错误(与同文件 {@link droppedKeys} 逐字同纪律)。
@@ -299,7 +365,7 @@ function salvageEntriesUnsafe(schema, raw) {
299
365
  keptFrom.push(i); // 压缩后第 keptFrom.length-1 条 = 原文档第 i 条
300
366
  return true;
301
367
  }
302
- bad.push(droppedEntry([field, i], error, entry));
368
+ bad.push(droppedEntry(outgoingPath([field, i]), error, entry));
303
369
  return false;
304
370
  });
305
371
  // 零幸存者 ⇒ 没有要抢救的东西(见头注:绝不凭空造出一个空集合)。
@@ -319,7 +385,7 @@ function salvageEntriesUnsafe(schema, raw) {
319
385
  if (error === undefined)
320
386
  kept[key] = entry;
321
387
  else
322
- bad.push(droppedEntry([field, redactSecrets(key)], error, entry)); // 键是用户写的,可能就是一条粘错位置的凭据
388
+ bad.push(droppedEntry(outgoingPath([field, key]), error, entry)); // 键是用户写的,可能就是一条粘错位置的凭据
323
389
  }
324
390
  if (bad.length > 0 && Object.keys(kept).length > 0) {
325
391
  dropped.push(...bad);
@@ -399,7 +465,7 @@ export const WARNING_KINDS = [
399
465
  export function projectWarning(w) {
400
466
  switch (w.kind) {
401
467
  case "hosts-grandfathered":
402
- return { kind: w.kind, domain: w.domain, droppedNames: w.droppedNames, ...(w.truncatedFrom === undefined ? {} : { truncatedFrom: w.truncatedFrom }) };
468
+ return { kind: w.kind, domain: w.domain, droppedNames: w.droppedNames, ...(w.droppedTotal === undefined ? {} : { droppedTotal: w.droppedTotal }), ...(w.truncatedFrom === undefined ? {} : { truncatedFrom: w.truncatedFrom }) };
403
469
  case "domain-defaulted":
404
470
  return { kind: w.kind, domain: w.domain };
405
471
  // 两臂字段相同但**不合并**:合并后 `kind` 塌成两员的联合,TS 就再也认不出它属于投影联合的哪一员
@@ -576,7 +642,7 @@ function collectDropped(domain, raw, parsed, prefix, depth, spared, acc) {
576
642
  continue; // 扩展命名空间:按契约放的键,不是未知键
577
643
  acc.total += 1;
578
644
  if (acc.keys.length < DROP_SCAN_MAX_KEYS)
579
- acc.keys.push([...prefix, k]);
645
+ acc.keys.push(outgoingPath([...prefix, k]));
580
646
  continue;
581
647
  }
582
648
  collectDropped(domain, raw[k], parsed[k], [...prefix, k], depth + 1, spared, acc);
@@ -662,7 +728,7 @@ export function carriedUnknownKeys(domain, parsed) {
662
728
  continue;
663
729
  acc.total += 1;
664
730
  if (acc.keys.length < DROP_SCAN_MAX_KEYS)
665
- acc.keys.push([...at, k]);
731
+ acc.keys.push(outgoingPath([...at, k]));
666
732
  }
667
733
  }
668
734
  return acc;
@@ -746,15 +812,23 @@ export const DOMAIN_READ_FALLBACK = {
746
812
  * observable: a deployment that has not seen that warning across a full read cycle no longer needs the arm. */
747
813
  export function grandfatherHostsRead(raw) {
748
814
  if (raw === null || typeof raw !== "object" || !Array.isArray(raw.hosts)) {
749
- return { raw, droppedNames: [], keptIndex: NO_ENTRIES_DROPPED }; // not the expected shape — let parseDomain rule on it
815
+ return { raw, droppedNames: [], droppedTotal: 0, keptIndex: NO_ENTRIES_DROPPED }; // not the expected shape — let parseDomain rule on it
750
816
  }
751
817
  const original = raw.hosts;
752
818
  const droppedNames = [];
819
+ let droppedTotal = 0;
753
820
  const keptFrom = [];
754
821
  let hosts = original.filter((h, i) => {
755
822
  const name = h !== null && typeof h === "object" ? h.name : undefined;
756
823
  if (typeof name === "string" && name.length > HOST_NAME_MAX) {
757
- droppedNames.push(name.slice(0, HOST_NAME_MAX) + "…"); // audit label — clipped so the warning itself stays bounded
824
+ droppedTotal += 1;
825
+ // 先钳后扫(S-813):只给前 DROP_SCAN_MAX_KEYS 个铸标签,其余只计数 —— 出门那一道每个名都要归一化一遍,不能随文档条数放大。
826
+ if (droppedNames.length >= DROP_SCAN_MAX_KEYS)
827
+ return false;
828
+ // 出门前先过同一道(6.3.0,opus 腿 M2):用户写的主机名完全可能就是一条粘错位置的凭据,夹紧只管载荷上界、不管秘密 ——
829
+ // 此前 `sk-live-…` 形 / 编码 PEM 形以 63 字原文前缀出门。判在夹紧**之前**(截断会把秘密形截成探测器认不出的半截)。
830
+ const out = outgoingSegment(name);
831
+ droppedNames.push(out.length > HOST_NAME_MAX ? out.slice(0, HOST_NAME_MAX) + "…" : out); // audit label — clipped so the warning itself stays bounded
758
832
  return false;
759
833
  }
760
834
  keptFrom.push(i); // 夹紧后第 keptFrom.length-1 条 = 原文档第 i 条
@@ -765,11 +839,11 @@ export function grandfatherHostsRead(raw) {
765
839
  truncatedFrom = hosts.length;
766
840
  hosts = hosts.slice(0, HOSTS_ARRAY_MAX);
767
841
  }
768
- if (droppedNames.length === 0 && truncatedFrom === undefined)
769
- return { raw, droppedNames, keptIndex: NO_ENTRIES_DROPPED }; // identity — byte-stable
842
+ if (droppedTotal === 0 && truncatedFrom === undefined)
843
+ return { raw, droppedNames, droppedTotal, keptIndex: NO_ENTRIES_DROPPED }; // identity — byte-stable
770
844
  // 位移**只**来自按名剔条目;纯截断留下的是原数组前缀,幸存者下标不动 ⇒ 不登记(见 EntryKeptIndex)。
771
- const keptIndex = droppedNames.length > 0 ? { hosts: keptFrom.slice(0, hosts.length) } : NO_ENTRIES_DROPPED;
772
- return { raw: { ...raw, hosts }, droppedNames, truncatedFrom, keptIndex };
845
+ const keptIndex = droppedTotal > 0 ? { hosts: keptFrom.slice(0, hosts.length) } : NO_ENTRIES_DROPPED;
846
+ return { raw: { ...raw, hosts }, droppedNames, droppedTotal, truncatedFrom, keptIndex };
773
847
  }
774
848
  export function buildEffective(version, getRaw, updatedAt, onWarning) {
775
849
  // 0.19.0 静默剥键/静默承运门:解析**成功**的域,把 schema 丢掉的键、以及 open-world 域里承运但本包
@@ -846,8 +920,9 @@ export function buildEffective(version, getRaw, updatedAt, onWarning) {
846
920
  // hosts: the PRECISE grandfather for the 0.8.0 tightening runs BEFORE parse (0.7-era legal data over the
847
921
  // new bounds reads on, clamped + warned); any residual corruption falls to the generic guardrail above.
848
922
  const hostsGf = grandfatherHostsRead(getRaw("hosts"));
849
- if (hostsGf.droppedNames.length > 0 || hostsGf.truncatedFrom !== undefined) {
850
- onWarning?.({ domain: "hosts", kind: "hosts-grandfathered", droppedNames: hostsGf.droppedNames, truncatedFrom: hostsGf.truncatedFrom });
923
+ if (hostsGf.droppedTotal > 0 || hostsGf.truncatedFrom !== undefined) {
924
+ const clipped = hostsGf.droppedTotal > hostsGf.droppedNames.length ? { droppedTotal: hostsGf.droppedTotal } : {};
925
+ onWarning?.({ domain: "hosts", kind: "hosts-grandfathered", droppedNames: hostsGf.droppedNames, ...clipped, truncatedFrom: hostsGf.truncatedFrom });
851
926
  }
852
927
  return {
853
928
  version,
@@ -15,11 +15,11 @@
15
15
  * HELD cross-session message awaits approval」。**注意它有两个消费面**:远端停靠的审批对话框,与
16
16
  * 停审的跨会话消息;本模块只声明键形,哪个宿主消费哪一面由宿主自陈。
17
17
  *
18
- * ## `"default"` 这第四个拼写
18
+ * ## `"default"` 这第五个拼写
19
19
  *
20
- * 设置 UI 的选择器需要一个「不设」的可写值,CC 用字面量 `"default"`。它**不是第四种策略**:读取器
20
+ * 设置 UI 的选择器需要一个「不设」的可写值,CC 用字面量 `"default"`。它**不是第五种策略**:读取器
21
21
  * (`readCrossSessionInbound` / `readDialogExpiry`)把它与 `null`/缺席一同折成 `undefined`。所以
22
- * `CrossSessionInboundSetting` 的受理面是四拼写,而**值域闭集**(`CROSS_SESSION_INBOUND_VALUES`)只有三个。
22
+ * `CrossSessionInboundSetting` 的受理面是五拼写,而**值域闭集**(`CROSS_SESSION_INBOUND_VALUES`)只有四个。
23
23
  *
24
24
  * ## 坏值方向 = 响亮,不静默
25
25
  *
@@ -29,8 +29,11 @@
29
29
  *
30
30
  * ## 与引擎词表的锁步
31
31
  *
32
- * 引擎另有一份同词表(判定纯函数的输入域)。两份**必须逐字相等**;等值由消费方(同时装了本包与引擎
33
- * 的那一侧)用一格锁步钉住 —— 任一侧加词/改序,那一格先红。本包不 import 引擎(纯度不变量)。
32
+ * 两张词表与 dialogExpiry 缺省的语义属主是引擎(判定纯函数的输入域;缺省也铸在引擎),本模块的三只常量是
33
+ * 它们的**登记镜像** —— 保留镜像是因为本包不能运行期依赖引擎,删掉它们两键的 schema 就只剩 `z.string()`。
34
+ * 两份**必须逐字同序相等**;等值由**本仓自证**:`test/upstream-drift.test.ts` ④ 在 dev 期读引擎的运行期导出,
35
+ * 登记表恒空(零豁免)、顺序另钉 —— 任一侧加词/改序/改缺省,那一格先红。消费方不再替本仓守这面镜像,只钉
36
+ * 「自己读的是引擎那份」。本包不 import 引擎(纯度不变量;引擎只在 devDependency)。
34
37
  */
35
38
  import { z } from "zod";
36
39
  /**
@@ -15,11 +15,11 @@
15
15
  * HELD cross-session message awaits approval」。**注意它有两个消费面**:远端停靠的审批对话框,与
16
16
  * 停审的跨会话消息;本模块只声明键形,哪个宿主消费哪一面由宿主自陈。
17
17
  *
18
- * ## `"default"` 这第四个拼写
18
+ * ## `"default"` 这第五个拼写
19
19
  *
20
- * 设置 UI 的选择器需要一个「不设」的可写值,CC 用字面量 `"default"`。它**不是第四种策略**:读取器
20
+ * 设置 UI 的选择器需要一个「不设」的可写值,CC 用字面量 `"default"`。它**不是第五种策略**:读取器
21
21
  * (`readCrossSessionInbound` / `readDialogExpiry`)把它与 `null`/缺席一同折成 `undefined`。所以
22
- * `CrossSessionInboundSetting` 的受理面是四拼写,而**值域闭集**(`CROSS_SESSION_INBOUND_VALUES`)只有三个。
22
+ * `CrossSessionInboundSetting` 的受理面是五拼写,而**值域闭集**(`CROSS_SESSION_INBOUND_VALUES`)只有四个。
23
23
  *
24
24
  * ## 坏值方向 = 响亮,不静默
25
25
  *
@@ -29,8 +29,11 @@
29
29
  *
30
30
  * ## 与引擎词表的锁步
31
31
  *
32
- * 引擎另有一份同词表(判定纯函数的输入域)。两份**必须逐字相等**;等值由消费方(同时装了本包与引擎
33
- * 的那一侧)用一格锁步钉住 —— 任一侧加词/改序,那一格先红。本包不 import 引擎(纯度不变量)。
32
+ * 两张词表与 dialogExpiry 缺省的语义属主是引擎(判定纯函数的输入域;缺省也铸在引擎),本模块的三只常量是
33
+ * 它们的**登记镜像** —— 保留镜像是因为本包不能运行期依赖引擎,删掉它们两键的 schema 就只剩 `z.string()`。
34
+ * 两份**必须逐字同序相等**;等值由**本仓自证**:`test/upstream-drift.test.ts` ④ 在 dev 期读引擎的运行期导出,
35
+ * 登记表恒空(零豁免)、顺序另钉 —— 任一侧加词/改序/改缺省,那一格先红。消费方不再替本仓守这面镜像,只钉
36
+ * 「自己读的是引擎那份」。本包不 import 引擎(纯度不变量;引擎只在 devDependency)。
34
37
  */
35
38
  import { z } from "zod";
36
39
  // ── crossSessionInbound ────────────────────────────────────────────────────────────────────────────
package/dist/fleet.d.ts CHANGED
@@ -81,6 +81,107 @@ export type RestartSignal = z.infer<typeof RestartSignal>;
81
81
  * Presence law: only `restart` counts; the top-level `restartRequired` boolean the server also ships is not read.
82
82
  */
83
83
  export declare function readRestartSignal(health: unknown): RestartSignal | undefined;
84
+ /**
85
+ * The model-refusal cause codes (S-768) — CLOSED sets, owned HERE, never a hand-copied second list. Adding a code is
86
+ * one edit in this file; who goes red at compile time: a reader that switches on the code exhaustively (a center), and
87
+ * a producer only where it holds a table KEYED BY the code (an exhaustive `switch` over the code, or a
88
+ * `Record<ModelEntryRefusalCode, …>`) — a producer table that merely `satisfies` these codes as values proves a subset,
89
+ * not equality, and stays green.
90
+ *
91
+ * - {@link MODEL_ENTRY_REFUSAL_CODES} — why ONE catalog entry is not served on this host (the other entries keep serving):
92
+ * `config.model_no_brain` — the entry's provider rides a brain this host has not mounted;
93
+ * `config.model_entry_invalid` — the entry does not match the `ModelEntry` shape (dropped by the read, or re-judged at
94
+ * the engine's own second boundary).
95
+ * - {@link MODEL_PLANE_REFUSAL_CODES} — why the model plane of the catalog the engine was handed was refused AS A GROUP
96
+ * (the engine keeps serving the model plane it had). This set mirrors the engine's group-refusal code TABLE as a
97
+ * whole family — never picked by which invariant is reachable on the hot path today (reachability moves, the table
98
+ * does not):
99
+ * `config.default_model_missing` — the candidate has no default model;
100
+ * `config.model_refused_name_minted` — a refused entry's name would be re-minted onto another entry by the
101
+ * tier / alias expansion (or the dropped-entry carrier cannot prove it would not be);
102
+ * `config.model_route_endpoint_missing` — an openai-compatible route of the candidate resolves to no endpoint;
103
+ * `config.gateway_fallback_without_primary` — fallback gateways are declared without a primary.
104
+ * Source (engine): the first two in `src/model-entry-refusal.ts`, the last two in `src/model-route-endpoint.ts:40,42`
105
+ * (invariant `model-route-endpoint`, judged by the model-plane stage in `src/config-center/apply-effective.ts:664-665`).
106
+ */
107
+ export declare const MODEL_ENTRY_REFUSAL_CODES: readonly ["config.model_no_brain", "config.model_entry_invalid"];
108
+ export declare const ModelEntryRefusalCode: z.ZodEnum<["config.model_no_brain", "config.model_entry_invalid"]>;
109
+ export type ModelEntryRefusalCode = z.infer<typeof ModelEntryRefusalCode>;
110
+ export declare const MODEL_PLANE_REFUSAL_CODES: readonly ["config.default_model_missing", "config.model_refused_name_minted", "config.model_route_endpoint_missing", "config.gateway_fallback_without_primary"];
111
+ export declare const ModelPlaneRefusalCode: z.ZodEnum<["config.default_model_missing", "config.model_refused_name_minted", "config.model_route_endpoint_missing", "config.gateway_fallback_without_primary"]>;
112
+ export type ModelPlaneRefusalCode = z.infer<typeof ModelPlaneRefusalCode>;
113
+ /**
114
+ * "This instance does not serve part (or all) of the model catalog it was handed" — the engine's OWN per-host verdict
115
+ * (S-768), defined ONCE for both exits that carry it: the fleet heartbeat ({@link InstanceAnnouncement}`.modelRefusals`)
116
+ * and GET /health (`modelRefusals`; {@link InstanceHealth} keeps carrying it untyped, as it does `restart`). One mint point,
117
+ * two exits, ONE reader for both: {@link readModelRefusals}.
118
+ *
119
+ * Two faces, split on purpose — the failure direction must match the meaning (this is a DIAGNOSTIC key; an unknown word
120
+ * in it must never make a healthy instance unroutable):
121
+ * - {@link ModelRefusalsWire} — what rides the wire and what a stored heartbeat row holds: the SHAPE law only, codes as
122
+ * open non-empty strings. A code this package version does not know passes (it is "not said" to this reader, and the
123
+ * stored row still carries it for a reader that is upgraded later).
124
+ * - {@link ModelRefusalsSignal} — what {@link readModelRefusals} returns: the wire narrowed to the closed sets above (an
125
+ * unknown entry code or plane code is dropped; narrowed to nothing ⇒ `undefined`, rendered like "not said"). The
126
+ * closed-set types stay the compile-time red source for readers when a code is added.
127
+ *
128
+ * The wire shape:
129
+ * - `entries` — catalog entry NAME → its code. The value is ONLY the code, on purpose: the engine's refusal fact also
130
+ * holds the upstream `id`, the `provider` word, the bad field keys and an operator sentence, and none of them rides
131
+ * here — an `entry_invalid` row is by definition one nothing validated, so its `id` / `provider` may be whatever the
132
+ * broken row held, and the sentence is prose, not contract. "Which name, which reason" is the whole wire. A name that
133
+ * cannot ride out is treated as NAMELESS — the engine leaves it out of this map entirely (not keyed, not counted). The
134
+ * ONE predicate is `isReportableRefusalName(name)` (main barrel): this schema can carry the key (non-empty; `__proto__`
135
+ * is silently dropped by the record, so it cannot) AND `isDisclosableIdentity(name)` (an identifier in the closed allowlist
136
+ * `DISCLOSABLE_IDENTITY_RE` — `A-Z a-z 0-9 . _ : / @ + -`, 1–256 chars — AND not secret-shaped, a URL userinfo included; the
137
+ * rule the dropped-entry identity on read warnings already uses, 6.3.0). A producer calls it before keying; this schema deliberately
138
+ * does not re-judge secrecy (see the 6.2.0 notes).
139
+ * - `plane` — present ⇔ the model plane of the latest catalog the engine was handed was refused as a group (the engine
140
+ * keeps serving the model plane it had); ONE code, never a list — one candidate has exactly one group verdict (the
141
+ * engine judges the group invariants in a fixed order and stops at the first that fails; a refusal from the Runner
142
+ * swap step carries no code and is outside this signal).
143
+ * - `entries` and `plane` describe the same latest candidate catalog — the one the engine was handed last; with `plane`
144
+ * present, that is the refused candidate (its per-entry verdicts are usually why it has no default). The engine does
145
+ * not swap its own refusal table while the group stays refused (its selection channels still refuse by the previous
146
+ * generation), so with `plane` present read `entries` as "why this catalog cannot land", not "what a pick is refused
147
+ * right now".
148
+ *
149
+ * PRESENCE is the signal (same law as {@link RestartSignal}): present ⇔ at least one reportable refusal (an entry refusal
150
+ * under a name that rides out, or a group refusal). An empty signal (no entries and no `plane`) is refused by the shape
151
+ * law — "none" has exactly one spelling, the absent key. Inside, "no entry refusals" is spelled `entries: {}` (the key is
152
+ * required) — one spelling at each level.
153
+ */
154
+ export declare const ModelRefusalsWire: z.ZodEffects<z.ZodObject<{
155
+ entries: z.ZodRecord<z.ZodString, z.ZodString>;
156
+ plane: z.ZodOptional<z.ZodString>;
157
+ }, "strip", z.ZodTypeAny, {
158
+ entries: Record<string, string>;
159
+ plane?: string | undefined;
160
+ }, {
161
+ entries: Record<string, string>;
162
+ plane?: string | undefined;
163
+ }>, {
164
+ entries: Record<string, string>;
165
+ plane?: string | undefined;
166
+ }, {
167
+ entries: Record<string, string>;
168
+ plane?: string | undefined;
169
+ }>;
170
+ export type ModelRefusalsWire = z.infer<typeof ModelRefusalsWire>;
171
+ /** {@link ModelRefusalsWire} narrowed to the closed code sets — the return type of {@link readModelRefusals}. */
172
+ export type ModelRefusalsSignal = {
173
+ entries: Record<string, ModelEntryRefusalCode>;
174
+ plane?: ModelPlaneRefusalCode;
175
+ };
176
+ /**
177
+ * The ONE reader of the model-refusals signal, for BOTH exits: pass a /health body or a parsed
178
+ * {@link InstanceAnnouncement} — it reads the `modelRefusals` key of whatever it is given. Shape mismatch, a missing key, a
179
+ * non-object body, even a throwing accessor ⇒ `undefined` — never throws. Then it narrows to the closed sets: an unknown
180
+ * entry code drops that entry, an unknown plane code drops `plane`; narrowed to nothing ⇒ `undefined`. So `undefined`
181
+ * means "nothing this reader can say"; whether that is "none" or "unknown" depends on whether the engine reports this key
182
+ * at all — it answers the key only and never judges the engine's version (see {@link InstanceAnnouncement}`.modelRefusals`).
183
+ */
184
+ export declare function readModelRefusals(body: unknown): ModelRefusalsSignal | undefined;
84
185
  /** The `/health` self-description every orchestrator reads (shipped shape, server 1.96.0). Liveness and
85
186
  * readiness are deliberately SPLIT: `status` stays ok while draining (the process is alive); routability is
86
187
  * `isReadyForTraffic`. Unknown fields pass through — health is a self-description, not a closed record. */
@@ -150,6 +251,32 @@ export declare const InstanceAnnouncement: z.ZodObject<{
150
251
  version: number;
151
252
  since: number;
152
253
  }>>;
254
+ /** S-768: present ⇔ this instance has at least one reportable model refusal — the SAME {@link ModelRefusalsWire} /health
255
+ * carries, minted by the same getter; read it with {@link readModelRefusals}`(announcement)` (never off this field
256
+ * directly: the field is the open wire form, the reader narrows to the closed sets). Every heartbeat re-announces the
257
+ * full row, so a later heartbeat WITHOUT it means "no reportable refusals any more" (the stored row is replaced, never
258
+ * merged). Reading absence: from an engine that reports this key, absent = none reportable; an engine that predates
259
+ * the key never sends it, so there absence is UNKNOWN, not "none" — tell the two apart by the announced `version`.
260
+ * Failure direction = meaning direction (this is a diagnostic key): a code this package version does not know passes
261
+ * (it is "not said" to the reader), and a value that breaks the {@link ModelRefusalsWire} shape law is dropped to
262
+ * ABSENT (`.catch`) — it never fails the announcement, so it can never make a healthy instance unroutable (unlike
263
+ * `restart`, whose shape still gates the row). The key may then be present with the value `undefined`. */
264
+ modelRefusals: z.ZodCatch<z.ZodOptional<z.ZodEffects<z.ZodObject<{
265
+ entries: z.ZodRecord<z.ZodString, z.ZodString>;
266
+ plane: z.ZodOptional<z.ZodString>;
267
+ }, "strip", z.ZodTypeAny, {
268
+ entries: Record<string, string>;
269
+ plane?: string | undefined;
270
+ }, {
271
+ entries: Record<string, string>;
272
+ plane?: string | undefined;
273
+ }>, {
274
+ entries: Record<string, string>;
275
+ plane?: string | undefined;
276
+ }, {
277
+ entries: Record<string, string>;
278
+ plane?: string | undefined;
279
+ }>>>;
153
280
  }, "strip", z.ZodTypeAny, {
154
281
  draining: boolean;
155
282
  version: string;
@@ -163,6 +290,10 @@ export declare const InstanceAnnouncement: z.ZodObject<{
163
290
  version: number;
164
291
  since: number;
165
292
  } | undefined;
293
+ modelRefusals?: {
294
+ entries: Record<string, string>;
295
+ plane?: string | undefined;
296
+ } | undefined;
166
297
  }, {
167
298
  version: string;
168
299
  instanceId: string;
@@ -175,6 +306,7 @@ export declare const InstanceAnnouncement: z.ZodObject<{
175
306
  version: number;
176
307
  since: number;
177
308
  } | undefined;
309
+ modelRefusals?: unknown;
178
310
  heartbeatMs?: number | undefined;
179
311
  }>;
180
312
  export type InstanceAnnouncement = z.infer<typeof InstanceAnnouncement>;
@@ -223,6 +355,32 @@ export declare const InstanceRegistration: z.ZodObject<{
223
355
  version: number;
224
356
  since: number;
225
357
  }>>;
358
+ /** S-768: present ⇔ this instance has at least one reportable model refusal — the SAME {@link ModelRefusalsWire} /health
359
+ * carries, minted by the same getter; read it with {@link readModelRefusals}`(announcement)` (never off this field
360
+ * directly: the field is the open wire form, the reader narrows to the closed sets). Every heartbeat re-announces the
361
+ * full row, so a later heartbeat WITHOUT it means "no reportable refusals any more" (the stored row is replaced, never
362
+ * merged). Reading absence: from an engine that reports this key, absent = none reportable; an engine that predates
363
+ * the key never sends it, so there absence is UNKNOWN, not "none" — tell the two apart by the announced `version`.
364
+ * Failure direction = meaning direction (this is a diagnostic key): a code this package version does not know passes
365
+ * (it is "not said" to the reader), and a value that breaks the {@link ModelRefusalsWire} shape law is dropped to
366
+ * ABSENT (`.catch`) — it never fails the announcement, so it can never make a healthy instance unroutable (unlike
367
+ * `restart`, whose shape still gates the row). The key may then be present with the value `undefined`. */
368
+ modelRefusals: z.ZodCatch<z.ZodOptional<z.ZodEffects<z.ZodObject<{
369
+ entries: z.ZodRecord<z.ZodString, z.ZodString>;
370
+ plane: z.ZodOptional<z.ZodString>;
371
+ }, "strip", z.ZodTypeAny, {
372
+ entries: Record<string, string>;
373
+ plane?: string | undefined;
374
+ }, {
375
+ entries: Record<string, string>;
376
+ plane?: string | undefined;
377
+ }>, {
378
+ entries: Record<string, string>;
379
+ plane?: string | undefined;
380
+ }, {
381
+ entries: Record<string, string>;
382
+ plane?: string | undefined;
383
+ }>>>;
226
384
  } & {
227
385
  registeredMs: z.ZodNumber;
228
386
  lastSeenMs: z.ZodNumber;
@@ -242,6 +400,10 @@ export declare const InstanceRegistration: z.ZodObject<{
242
400
  version: number;
243
401
  since: number;
244
402
  } | undefined;
403
+ modelRefusals?: {
404
+ entries: Record<string, string>;
405
+ plane?: string | undefined;
406
+ } | undefined;
245
407
  observedReady?: boolean | undefined;
246
408
  }, {
247
409
  version: string;
@@ -257,6 +419,7 @@ export declare const InstanceRegistration: z.ZodObject<{
257
419
  version: number;
258
420
  since: number;
259
421
  } | undefined;
422
+ modelRefusals?: unknown;
260
423
  heartbeatMs?: number | undefined;
261
424
  observedReady?: boolean | undefined;
262
425
  }>;