@sema-agent/settings-schema 14.4.0 → 14.6.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 (59) hide show
  1. package/CHANGELOG.md +303 -0
  2. package/README.md +22 -1
  3. package/conformance/VERSION +1 -1
  4. package/dist/api/scopes.d.ts +1 -1
  5. package/dist/api/scopes.js +1 -1
  6. package/dist/atomic-write.d.ts +11 -0
  7. package/dist/atomic-write.js +38 -5
  8. package/dist/bundle.d.ts +1 -1
  9. package/dist/bundle.js +1 -1
  10. package/dist/config-d-file.d.ts +4 -0
  11. package/dist/config-d-file.js +11 -4
  12. package/dist/config-dir-io.js +12 -2
  13. package/dist/config-dir-migration.d.ts +44 -0
  14. package/dist/config-dir-migration.js +91 -36
  15. package/dist/config-fns.js +3 -9
  16. package/dist/conformance/canonical.js +4 -7
  17. package/dist/conformance/corpus.js +3 -3
  18. package/dist/conformance/derive.js +6 -5
  19. package/dist/conformance/run.js +2 -1
  20. package/dist/definition-copy.d.ts +23 -0
  21. package/dist/definition-copy.js +52 -0
  22. package/dist/file-store.d.ts +1 -31
  23. package/dist/file-store.js +3 -30
  24. package/dist/fleet.js +2 -1
  25. package/dist/fs-failure.d.ts +17 -2
  26. package/dist/fs-failure.js +55 -15
  27. package/dist/hooks-config.d.ts +2 -2
  28. package/dist/hooks-config.js +112 -31
  29. package/dist/http-hook-allowlists.d.ts +7 -3
  30. package/dist/http-hook-allowlists.js +102 -16
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.js +3 -1
  33. package/dist/mcp-request-entry.d.ts +9 -6
  34. package/dist/mcp-request-entry.js +225 -93
  35. package/dist/node.d.ts +2 -1
  36. package/dist/node.js +2 -1
  37. package/dist/one-time-migrations.js +36 -41
  38. package/dist/plain-json-object.d.ts +31 -0
  39. package/dist/plain-json-object.js +52 -0
  40. package/dist/portable-domains.d.ts +31 -0
  41. package/dist/portable-domains.js +30 -0
  42. package/dist/safety-merge-spec.d.ts +88 -6
  43. package/dist/safety-merge-spec.js +233 -17
  44. package/dist/scheduler-store.js +3 -2
  45. package/dist/settings-units.d.ts +85 -0
  46. package/dist/settings-units.js +175 -0
  47. package/dist/types.d.ts +4 -0
  48. package/dist/types.js +61 -53
  49. package/dist/url-credential.d.ts +41 -0
  50. package/dist/url-credential.js +95 -0
  51. package/dist/url-shape.d.ts +11 -5
  52. package/dist/url-shape.js +11 -5
  53. package/dist/workers-rows.d.ts +2 -1
  54. package/dist/workers-rows.js +5 -4
  55. package/docs/API-SURFACE.md +30 -4
  56. package/docs/CONSUMER-INTEGRATION.md +2 -0
  57. package/llms-full.txt +24 -2
  58. package/llms.txt +6 -2
  59. package/package.json +2 -3
@@ -33,6 +33,7 @@ import { outgoingPath, redactSecrets } from "./config-fns.js";
33
33
  import { LANE_ENV_OWNER, STORE_ENV_KEYS, isLaneEnvKey } from "./execution-lane-index.js";
34
34
  import { EXECUTION_LANE_WORDS, MEMORY_ENGINE_BACKENDS, STORE_BACKENDS } from "./execution-lanes.js";
35
35
  import { INTEGER } from "./integer.js";
36
+ import { isPlainJsonObject } from "./plain-json-object.js";
36
37
  import { RUNTIME_LIFT_TARGETS } from "./runtime-lift-targets.js";
37
38
  import { RETIRED_ROLE_WORDS } from "./retired-role-words.js";
38
39
  import { REMOTE_EXEC_DOCUMENT, RemoteExecSpec } from "./remote-exec.js";
@@ -197,7 +198,7 @@ export const ONE_TIME_MIGRATIONS = [
197
198
  * 重跑恒等:第二次跑零改动,只剩第一次就 `refused` 的那几张收据(那些坐标原样留着,读侧照旧拒)。
198
199
  */
199
200
  export function migrateStoredDocuments(docs, opts) {
200
- if (!isPlainObject(docs))
201
+ if (!isPlainJsonObject(docs))
201
202
  throw new TypeError("migrateStoredDocuments: `docs` must be a { domain: document } object");
202
203
  switch (opts.coverage) {
203
204
  case "scope":
@@ -229,7 +230,7 @@ export function migrateStoredDocuments(docs, opts) {
229
230
  * 不是对象 ⇒ 原样返回(判形是读者的事)。
230
231
  */
231
232
  export function migrateEffectiveSnapshot(effective) {
232
- if (!isPlainObject(effective))
233
+ if (!isPlainJsonObject(effective))
233
234
  return { effective, receipts: [] };
234
235
  const docs = {};
235
236
  for (const [k, v] of Object.entries(effective))
@@ -251,13 +252,7 @@ export function migrateEffectiveSnapshot(effective) {
251
252
  next[k] = v;
252
253
  return { effective: next, receipts: m.receipts };
253
254
  }
254
- function isPlainObject(v) {
255
- if (v === null || typeof v !== "object" || Array.isArray(v))
256
- return false;
257
- const proto = Object.getPrototypeOf(v);
258
- return proto === Object.prototype || proto === null;
259
- }
260
- const has = (o, k) => isPlainObject(o) && Object.prototype.hasOwnProperty.call(o, k);
255
+ const has = (o, k) => isPlainJsonObject(o) && Object.prototype.hasOwnProperty.call(o, k);
261
256
  const without = (o, k) => {
262
257
  const { [k]: _gone, ...rest } = o;
263
258
  return rest;
@@ -291,12 +286,12 @@ function stepRuntimeDomain(out, coverage, receipts) {
291
286
  if (raw === undefined)
292
287
  return;
293
288
  const refuse = (reason) => void receipts.push(refused("SS-M1", "runtime", [], reason));
294
- if (isPlainObject(raw) && Object.keys(raw).length === 0) {
289
+ if (isPlainJsonObject(raw) && Object.keys(raw).length === 0) {
295
290
  delete out.runtime;
296
291
  receipts.push(receipt("SS-M1", "runtime", [], "dropped-inert"));
297
292
  return;
298
293
  }
299
- if (!isPlainObject(raw))
294
+ if (!isPlainJsonObject(raw))
300
295
  return refuse("the stored runtime document is not a JSON object — the scope does not read today either; fix it by hand");
301
296
  if (coverage === "partial" && !RUNTIME_LIFT_TARGETS.every((target) => has(out, target))) {
302
297
  return refuse("a runtime document migrates only together with its scope's governance and limits documents (coverage \"scope\") — on its own there is no telling whether governance was ever set");
@@ -305,7 +300,7 @@ function stepRuntimeDomain(out, coverage, receipts) {
305
300
  if (!parsed.success)
306
301
  return refuse("the stored runtime document does not read under the 10.x runtime schema — the scope does not read today either; fix it by hand");
307
302
  const limitsRaw = out.limits;
308
- if (limitsRaw !== undefined && !isPlainObject(limitsRaw))
303
+ if (limitsRaw !== undefined && !isPlainJsonObject(limitsRaw))
309
304
  return refuse("the scope's limits document is not a JSON object — the scope does not read today either; fix limits first");
310
305
  const trio = GOVERNANCE_TRIO.filter((k) => has(raw, k) && raw[k] !== undefined);
311
306
  if (trio.length > 0) {
@@ -323,7 +318,7 @@ function stepRuntimeDomain(out, coverage, receipts) {
323
318
  const limitKeys = LIMIT_KEYS.filter((k) => parsed.data[k] !== undefined);
324
319
  if (limitKeys.length > 0) {
325
320
  // 迁移前的读法(消费方):`limits` 有这一键 ⇒ limits 赢;没有 ⇒ 读 runtime 上的值。
326
- const limits = isPlainObject(limitsRaw) ? { ...limitsRaw } : {};
321
+ const limits = isPlainJsonObject(limitsRaw) ? { ...limitsRaw } : {};
327
322
  let added = false;
328
323
  for (const k of limitKeys) {
329
324
  if (Object.prototype.hasOwnProperty.call(limits, k))
@@ -346,13 +341,13 @@ function stepRuntimeDomain(out, coverage, receipts) {
346
341
  // ── SS-M2:hosts 超界 ───────────────────────────────────────────────────────────────────────────────────
347
342
  function stepHostsBounds(out, receipts) {
348
343
  const doc = out.hosts;
349
- if (!isPlainObject(doc) || !Array.isArray(doc.hosts))
344
+ if (!isPlainJsonObject(doc) || !Array.isArray(doc.hosts))
350
345
  return;
351
346
  const rows = doc.hosts;
352
347
  let kept = [];
353
348
  const dropped = [];
354
349
  rows.forEach((row, index) => {
355
- const name = isPlainObject(row) ? row.name : undefined;
350
+ const name = isPlainJsonObject(row) ? row.name : undefined;
356
351
  if (typeof name === "string" && name.length > HOST_NAME_MAX)
357
352
  dropped.push(index);
358
353
  else
@@ -402,8 +397,8 @@ const UNCHANGED = { kind: "unchanged" };
402
397
  function migrateWorkerRow(raw) {
403
398
  const refuse = (field, reason) => ({ kind: "refused", field, reason });
404
399
  const enabled = raw.enabled !== false;
405
- const laneKeysOf = (rec) => (isPlainObject(rec) ? Object.keys(rec).filter(isLaneEnvKey) : []);
406
- const storeKeysOf = (rec) => (isPlainObject(rec) ? STORE_ENV_KEYS.filter((k) => Object.hasOwn(rec, k)) : []);
400
+ const laneKeysOf = (rec) => (isPlainJsonObject(rec) ? Object.keys(rec).filter(isLaneEnvKey) : []);
401
+ const storeKeysOf = (rec) => (isPlainJsonObject(rec) ? STORE_ENV_KEYS.filter((k) => Object.hasOwn(rec, k)) : []);
407
402
  const envLaneKeys = laneKeysOf(raw.env);
408
403
  const secretLaneKeys = laneKeysOf(raw.secretEnv);
409
404
  const envStoreKeys = storeKeysOf(raw.env);
@@ -432,12 +427,12 @@ function migrateWorkerRow(raw) {
432
427
  const needLane = !hasExecution && (enabled || laneTouched);
433
428
  const needStore = !hasStore && (enabled || storeTouched);
434
429
  const w = structuredClone(raw);
435
- const runtime = isPlainObject(w.runtime) ? w.runtime : {};
430
+ const runtime = isPlainJsonObject(w.runtime) ? w.runtime : {};
436
431
  w.runtime = runtime;
437
- const env = isPlainObject(w.env) ? w.env : {};
438
- const secretEnv = isPlainObject(w.secretEnv) ? w.secretEnv : {};
439
- const coords = isPlainObject(w.coords) ? w.coords : {};
440
- const secretRefs = isPlainObject(w.secretRefs) ? w.secretRefs : {};
432
+ const env = isPlainJsonObject(w.env) ? w.env : {};
433
+ const secretEnv = isPlainJsonObject(w.secretEnv) ? w.secretEnv : {};
434
+ const coords = isPlainJsonObject(w.coords) ? w.coords : {};
435
+ const secretRefs = isPlainJsonObject(w.secretRefs) ? w.secretRefs : {};
441
436
  const dropped = [];
442
437
  const drop = (container, slot, key) => {
443
438
  delete container[key];
@@ -601,9 +596,9 @@ function migrateWorkerRow(raw) {
601
596
  }
602
597
  }
603
598
  // 被搬空的 env / secretEnv 整键去掉(它们是可选字段;留一个 `{}` 只是噪声)
604
- if (isPlainObject(w.env) && Object.keys(env).length === 0 && isPlainObject(raw.env) && Object.keys(raw.env).length > 0)
599
+ if (isPlainJsonObject(w.env) && Object.keys(env).length === 0 && isPlainJsonObject(raw.env) && Object.keys(raw.env).length > 0)
605
600
  delete w.env;
606
- if (isPlainObject(w.secretEnv) && Object.keys(secretEnv).length === 0 && isPlainObject(raw.secretEnv) && Object.keys(raw.secretEnv).length > 0)
601
+ if (isPlainJsonObject(w.secretEnv) && Object.keys(secretEnv).length === 0 && isPlainJsonObject(raw.secretEnv) && Object.keys(raw.secretEnv).length > 0)
607
602
  delete w.secretEnv;
608
603
  // 改写后的行仍读不进 7.x 形 ⇒ 不写回半成品:拒,原文留盘(读口 `readWorkersRows` 扣下它)。判据与拒句是读口那一只(`workerRowVerdict`)。
609
604
  const verdict = workerRowVerdict(w, "migrated");
@@ -613,12 +608,12 @@ function migrateWorkerRow(raw) {
613
608
  }
614
609
  function stepWorkers6x(out, receipts) {
615
610
  const doc = out.workers;
616
- if (!isPlainObject(doc) || !Array.isArray(doc.workers))
611
+ if (!isPlainJsonObject(doc) || !Array.isArray(doc.workers))
617
612
  return;
618
613
  const rows = doc.workers;
619
614
  let next;
620
615
  rows.forEach((row, index) => {
621
- if (!isPlainObject(row) || typeof row.name !== "string")
616
+ if (!isPlainJsonObject(row) || typeof row.name !== "string")
622
617
  return; // 认不出名字:整域读侧判
623
618
  const r = migrateWorkerRow(row);
624
619
  switch (r.kind) {
@@ -646,7 +641,7 @@ function stepWorkers6x(out, receipts) {
646
641
  // ── SS-M5:模型钥匙二选一 ──────────────────────────────────────────────────────────────────────────────
647
642
  function stepModelKeyOneOf(out, receipts) {
648
643
  const doc = out.models;
649
- if (!isPlainObject(doc) || !Array.isArray(doc.models))
644
+ if (!isPlainJsonObject(doc) || !Array.isArray(doc.models))
650
645
  return;
651
646
  const rows = doc.models;
652
647
  let next;
@@ -682,17 +677,17 @@ function rewriteAtMention(holder, document, at, receipts) {
682
677
  return { ...without(holder, "atModelAllowlist"), atMention };
683
678
  }
684
679
  function stepAtMention(out, receipts) {
685
- if (isPlainObject(out.models)) {
680
+ if (isPlainJsonObject(out.models)) {
686
681
  const next = rewriteAtMention(out.models, "models", [], receipts);
687
682
  if (next !== undefined)
688
683
  out.models = next;
689
684
  }
690
685
  const rosters = out.rosters;
691
- if (isPlainObject(rosters) && Array.isArray(rosters.rosters)) {
686
+ if (isPlainJsonObject(rosters) && Array.isArray(rosters.rosters)) {
692
687
  const rows = rosters.rosters;
693
688
  let next;
694
689
  rows.forEach((row, index) => {
695
- if (!isPlainObject(row))
690
+ if (!isPlainJsonObject(row))
696
691
  return;
697
692
  const r = rewriteAtMention(row, "rosters", ["rosters", index], receipts);
698
693
  if (r === undefined)
@@ -707,7 +702,7 @@ function stepAtMention(out, receipts) {
707
702
  // ── SS-M8:退役的角色词 ────────────────────────────────────────────────────────────────────────────────
708
703
  /** 一个 `roles` 图上的退役角色词键删掉(值不论 —— 没有角色读它);逐键一张 `dropped-inert` 收据。返回改后的图或 `undefined`(没改 / 不是对象:判形是读者的事)。 */
709
704
  function withoutRetiredRoles(roles, document, at, receipts) {
710
- if (!isPlainObject(roles))
705
+ if (!isPlainJsonObject(roles))
711
706
  return undefined;
712
707
  const present = RETIRED_ROLE_WORDS.filter((w) => Object.prototype.hasOwnProperty.call(roles, w));
713
708
  if (present.length === 0)
@@ -721,17 +716,17 @@ function withoutRetiredRoles(roles, document, at, receipts) {
721
716
  }
722
717
  function stepRetiredRoleWords(out, receipts) {
723
718
  const models = out.models;
724
- if (isPlainObject(models)) {
719
+ if (isPlainJsonObject(models)) {
725
720
  const roles = withoutRetiredRoles(models.roles, "models", [], receipts);
726
721
  if (roles !== undefined)
727
722
  out.models = { ...models, roles };
728
723
  }
729
724
  const rosters = out.rosters;
730
- if (isPlainObject(rosters) && Array.isArray(rosters.rosters)) {
725
+ if (isPlainJsonObject(rosters) && Array.isArray(rosters.rosters)) {
731
726
  const rows = rosters.rosters;
732
727
  let next;
733
728
  rows.forEach((row, index) => {
734
- if (!isPlainObject(row))
729
+ if (!isPlainJsonObject(row))
735
730
  return;
736
731
  const roles = withoutRetiredRoles(row.roles, "rosters", ["rosters", index], receipts);
737
732
  if (roles === undefined)
@@ -749,16 +744,16 @@ function stepRetiredRoleWords(out, receipts) {
749
744
  * 消失);异名 ⇒ 原 `roles.default` 作「被压过」删掉、`default` 搬入。`default` 不是非空串 ⇒ 拒(今天它让整个 models 域读不进,不替运维猜)。 */
750
745
  function stepModelsDefault(out, receipts) {
751
746
  const doc = out.models;
752
- if (!isPlainObject(doc) || !Object.prototype.hasOwnProperty.call(doc, "default"))
747
+ if (!isPlainJsonObject(doc) || !Object.prototype.hasOwnProperty.call(doc, "default"))
753
748
  return;
754
749
  const name = doc.default;
755
750
  if (typeof name !== "string" || name === "") {
756
751
  receipts.push(refused("SS-M9", "models", ["default"], "models.default is not a catalog name — it does not read today either; fix it by hand"));
757
752
  return;
758
753
  }
759
- const roles = isPlainObject(doc.roles) ? doc.roles : {};
754
+ const roles = isPlainJsonObject(doc.roles) ? doc.roles : {};
760
755
  const prior = roles.default;
761
- if (prior !== undefined && !(isPlainObject(prior) && prior.model === name))
756
+ if (prior !== undefined && !(isPlainJsonObject(prior) && prior.model === name))
762
757
  receipts.push(receipt("SS-M9", "models", ["roles", "default"], "dropped-shadowed"));
763
758
  receipts.push(moved("SS-M9", "models", ["default"], { document: "models", path: ["roles", "default"] }));
764
759
  out.models = { ...without(doc, "default"), roles: { ...roles, default: { model: name } } };
@@ -769,7 +764,7 @@ function stepModelsDefault(out, receipts) {
769
764
  * overlay,catalog 的 digest 是引擎铸的,替运维机械转就是第二个编译器 / 第二个铸点。`prompts` 不是对象 ⇒ 不动(判形是读者的事)。 */
770
765
  function stepPromptsAxes(out, receipts) {
771
766
  const doc = out.prompts;
772
- if (!isPlainObject(doc))
767
+ if (!isPlainJsonObject(doc))
773
768
  return;
774
769
  let next = doc;
775
770
  if (has(doc, "bindings") && doc.bindings !== undefined) {
@@ -820,7 +815,7 @@ function stripFragments(schema, value, path, onStrip, depth = 0) {
820
815
  return value.slice(0, hash);
821
816
  }
822
817
  if (s instanceof z.ZodDiscriminatedUnion) {
823
- if (!isPlainObject(value))
818
+ if (!isPlainJsonObject(value))
824
819
  return value;
825
820
  const disc = s._zod.def.discriminator;
826
821
  for (const option of s.options) {
@@ -842,7 +837,7 @@ function stripFragments(schema, value, path, onStrip, depth = 0) {
842
837
  return value;
843
838
  }
844
839
  if (s instanceof z.ZodObject) {
845
- if (!isPlainObject(value))
840
+ if (!isPlainJsonObject(value))
846
841
  return value;
847
842
  let next;
848
843
  for (const [k, fieldSchema] of Object.entries(s.shape)) {
@@ -866,7 +861,7 @@ function stripFragments(schema, value, path, onStrip, depth = 0) {
866
861
  return next ?? value;
867
862
  }
868
863
  if (s instanceof z.ZodRecord) {
869
- if (!isPlainObject(value))
864
+ if (!isPlainJsonObject(value))
870
865
  return value;
871
866
  let next;
872
867
  for (const [k, el] of Object.entries(value)) {
@@ -0,0 +1,31 @@
1
+ /**
2
+ * 包内私有(不进主桶、不进 exports 表):**「纯 JSON 对象」的唯一谓词** —— 不是 `null`、不是数组、原型是 `null` 或「原型自己的原型是 `null`」(即 `Object.prototype`:JSON 解析出来的对象恒如此;
3
+ * `Date` / `Map` / 类实例 / 建在别的对象原型上的记录原型链更深,都不是)。**已知的边**:原型是某个 null 原型对象的对象(`Object.create(Object.create(null))`)、`class X extends null` 的实例,
4
+ * 链上第二环也是 `null`,读成朴素 —— 它们只能由程序造出,JSON 解析不出来;读者按它的**自有**成员读,继承来的成员不计(托管层 http hook 名单对只靠继承可见的名单键 fail-closed;请求面 `env` / `headers` 收成 `{}` 不修 —— 读者级遍历继承成员与谓词级再加判据都对 `Object.prototype` 上的可枚举污染敏感,而这一侧丢的是数据、失败可见、不在安全轴)。**不按「原型恒等本 realm 的 `Object.prototype`」判**:另一个 realm(`vm` 新上下文、iframe)里 `JSON.parse` 出来的普通对象,
5
+ * 原型是**那个 realm** 的 `Object.prototype`,按恒等判会被读成非朴素;而任何 realm 的 `Object.prototype` 自身原型都是 `null`,所以链上第二环是 `null` 这一条与 realm 无关,不需要为 realm 加特例。包里凡要问「这个值是不是一个 JSON 对象」的地方都读它,不各写一份:读域文档的键集对比(`./config-fns`)、一次性迁移的文档形判
6
+ * (`./one-time-migrations`)、`config.d` 文档形(`./config-d-file`)、`extraBody` 的形闸与秘密走查(`./types`)、settings 单元读法与严格 JSON 深度判(`./settings-units` / `./safety-merge-spec`)、
7
+ * 请求面传输的记录读法与描述件(`./mcp-request-entry`)、workers 行读口(`./workers-rows`)、有效配置线上载荷读口(`./config-fns` 的 `readEffectiveWire`)、http hook 名单的设置袋(`./http-hook-allowlists`)、
8
+ * fleet 线上载荷的受护判(`./fleet`)、符合性语料的比较器 / 派生 / 语料读口 / 元数据拆分(`./conformance/*`)。零 import。
9
+ * **形状分类核 {@link jsonShapeOf} 是座位的本体**(唯一读原型的地方),`isPlainJsonObject` 由它派生(`plain` 那一格)。**总(不抛)**:检视就抛的对象(已撤销的 Proxy、`getPrototypeOf` 陷阱抛)
10
+ * 核答 `uninspectable`、判官答 `false`,不抛出去 —— 全包经座位的读口因此拒而不抛,律在座位上收一处,不在每个读器各包一个 `try`;读者对 `uninspectable` 一律按最严处置(hooks ⇒ 拒整份文档;名单 ⇒ `malformed`;传输 ⇒ 整只不交)。
11
+ *
12
+ * **唯一的第二处是 `./hooks-config` 的 `hookShapeOf` / `hookIsRecord`,与 {@link jsonShapeOf} / 本函数逐字同文(调用名归一后)**:hooks 分类器的闭包要被消费方生成器整函数抄走(闭包里的自由名只有 `HOOK_EVENTS`),闭包里不能 import 本模块 ——
13
+ * 同文由 `test/plain-json-object-single-seat-14.6.0.test.ts` 逐字证明(漂开即红),闭包契约多注入一个名字时(S-2133)把它并回这里。
14
+ * 同一个门也数全包:返回类型是 `x is Record<string, unknown>` 的函数、读原型的调用点,除这两处之外一处不许有;内联的「typeof 对象 ∧ 非 null ∧ 非数组」合取则**一处都不许有**(座位里它是分开的 `if` 臂,不是合取)。
15
+ *
16
+ * **读者要的答案不只是「是不是朴素对象」时,调本模块的 {@link jsonShapeOf}**(六词形状分类),不在读者里再推一遍形状:例如托管层的 http hook 名单,非朴素的**对象**袋必须 fail-closed(读成空名单并告警),
17
+ * 而 `null` / 原始值 / 数组仍是「整份设置不是对象 ⇒ 两键都缺」—— 两种不同的答案,只能由形状分类给。`isPlainJsonObject` 就是 `jsonShapeOf` 的「朴素」那一格。
18
+ */
19
+ export declare function isPlainJsonObject(v: unknown): v is Record<string, unknown>;
20
+ /** 一个值的形状(六词,闭集):`plain` = 朴素对象(原型是 `null` 或原型自己的原型是 `null`;{@link isPlainJsonObject} 就是这一格);`non_plain_object` = 是对象但不朴素(类实例 / `Map` / `Date` / 建在别的对象原型上的记录,以及**函数** —— 函数是对象、不是纯数据,
21
+ * JSON 里不会有;读者对「是对象但不朴素」一律 fail-closed,函数不能从缝里漏过去);`array`;`null`;`primitive` = 其余(字符串 / 数 / 布尔 / `undefined` / symbol / bigint);
22
+ * `uninspectable` = **读形状就抛**的对象(已撤销的 Proxy:连 `Array.isArray` 都抛;`getPrototypeOf` 陷阱抛的活 Proxy)—— 不是「不朴素」(那句要读者劝人「拷成纯对象」,对撤销的 Proxy 做不到),读者对它 fail-closed 并说全包唯一的「检视不了」那一句。
23
+ * 读者对这六个词**只许穷举 `switch` 加 `never` 默认臂**(加第七个词时每个读者编译红),不许 `===` 链逐个比(单座门看着)。 */
24
+ export type JsonShape = ReturnType<typeof jsonShapeOf>;
25
+ /** 值的形状分类 —— 本包里「是对象但不朴素」与「根本不是对象」要给不同答案的读者调它(见头注)。读原型的就是这一只(判官 {@link isPlainJsonObject} 由它派生)。 */
26
+ export declare function jsonShapeOf(v: unknown): "plain" | "non_plain_object" | "array" | "null" | "primitive" | "uninspectable";
27
+ /**
28
+ * `Array.isArray` 的**总**版(不抛):已撤销的 Proxy 连「是不是数组」都读不出 ⇒ 不是数组。读者要问「这个值是不是数组」且值可能来自嵌入方(非 JSON)时调它,不直接 `Array.isArray`。
29
+ * 分类器闭包里不能 import,`./hooks-config` 的 `hookIsArray` 与本函数**逐字同文**(同一个门证明,与 `hookIsRecord` 同律)。
30
+ */
31
+ export declare function isJsonArray(v: unknown): v is unknown[];
@@ -0,0 +1,52 @@
1
+ /**
2
+ * 包内私有(不进主桶、不进 exports 表):**「纯 JSON 对象」的唯一谓词** —— 不是 `null`、不是数组、原型是 `null` 或「原型自己的原型是 `null`」(即 `Object.prototype`:JSON 解析出来的对象恒如此;
3
+ * `Date` / `Map` / 类实例 / 建在别的对象原型上的记录原型链更深,都不是)。**已知的边**:原型是某个 null 原型对象的对象(`Object.create(Object.create(null))`)、`class X extends null` 的实例,
4
+ * 链上第二环也是 `null`,读成朴素 —— 它们只能由程序造出,JSON 解析不出来;读者按它的**自有**成员读,继承来的成员不计(托管层 http hook 名单对只靠继承可见的名单键 fail-closed;请求面 `env` / `headers` 收成 `{}` 不修 —— 读者级遍历继承成员与谓词级再加判据都对 `Object.prototype` 上的可枚举污染敏感,而这一侧丢的是数据、失败可见、不在安全轴)。**不按「原型恒等本 realm 的 `Object.prototype`」判**:另一个 realm(`vm` 新上下文、iframe)里 `JSON.parse` 出来的普通对象,
5
+ * 原型是**那个 realm** 的 `Object.prototype`,按恒等判会被读成非朴素;而任何 realm 的 `Object.prototype` 自身原型都是 `null`,所以链上第二环是 `null` 这一条与 realm 无关,不需要为 realm 加特例。包里凡要问「这个值是不是一个 JSON 对象」的地方都读它,不各写一份:读域文档的键集对比(`./config-fns`)、一次性迁移的文档形判
6
+ * (`./one-time-migrations`)、`config.d` 文档形(`./config-d-file`)、`extraBody` 的形闸与秘密走查(`./types`)、settings 单元读法与严格 JSON 深度判(`./settings-units` / `./safety-merge-spec`)、
7
+ * 请求面传输的记录读法与描述件(`./mcp-request-entry`)、workers 行读口(`./workers-rows`)、有效配置线上载荷读口(`./config-fns` 的 `readEffectiveWire`)、http hook 名单的设置袋(`./http-hook-allowlists`)、
8
+ * fleet 线上载荷的受护判(`./fleet`)、符合性语料的比较器 / 派生 / 语料读口 / 元数据拆分(`./conformance/*`)。零 import。
9
+ * **形状分类核 {@link jsonShapeOf} 是座位的本体**(唯一读原型的地方),`isPlainJsonObject` 由它派生(`plain` 那一格)。**总(不抛)**:检视就抛的对象(已撤销的 Proxy、`getPrototypeOf` 陷阱抛)
10
+ * 核答 `uninspectable`、判官答 `false`,不抛出去 —— 全包经座位的读口因此拒而不抛,律在座位上收一处,不在每个读器各包一个 `try`;读者对 `uninspectable` 一律按最严处置(hooks ⇒ 拒整份文档;名单 ⇒ `malformed`;传输 ⇒ 整只不交)。
11
+ *
12
+ * **唯一的第二处是 `./hooks-config` 的 `hookShapeOf` / `hookIsRecord`,与 {@link jsonShapeOf} / 本函数逐字同文(调用名归一后)**:hooks 分类器的闭包要被消费方生成器整函数抄走(闭包里的自由名只有 `HOOK_EVENTS`),闭包里不能 import 本模块 ——
13
+ * 同文由 `test/plain-json-object-single-seat-14.6.0.test.ts` 逐字证明(漂开即红),闭包契约多注入一个名字时(S-2133)把它并回这里。
14
+ * 同一个门也数全包:返回类型是 `x is Record<string, unknown>` 的函数、读原型的调用点,除这两处之外一处不许有;内联的「typeof 对象 ∧ 非 null ∧ 非数组」合取则**一处都不许有**(座位里它是分开的 `if` 臂,不是合取)。
15
+ *
16
+ * **读者要的答案不只是「是不是朴素对象」时,调本模块的 {@link jsonShapeOf}**(六词形状分类),不在读者里再推一遍形状:例如托管层的 http hook 名单,非朴素的**对象**袋必须 fail-closed(读成空名单并告警),
17
+ * 而 `null` / 原始值 / 数组仍是「整份设置不是对象 ⇒ 两键都缺」—— 两种不同的答案,只能由形状分类给。`isPlainJsonObject` 就是 `jsonShapeOf` 的「朴素」那一格。
18
+ */
19
+ export function isPlainJsonObject(v) {
20
+ const shape = jsonShapeOf(v);
21
+ return shape === "plain";
22
+ }
23
+ /** 值的形状分类 —— 本包里「是对象但不朴素」与「根本不是对象」要给不同答案的读者调它(见头注)。读原型的就是这一只(判官 {@link isPlainJsonObject} 由它派生)。 */
24
+ export function jsonShapeOf(v) {
25
+ try {
26
+ if (v === null)
27
+ return "null";
28
+ if (Array.isArray(v))
29
+ return "array";
30
+ if (typeof v === "function")
31
+ return "non_plain_object";
32
+ if (typeof v !== "object")
33
+ return "primitive";
34
+ const proto = Object.getPrototypeOf(v);
35
+ return proto === null || Object.getPrototypeOf(proto) === null ? "plain" : "non_plain_object";
36
+ }
37
+ catch {
38
+ return "uninspectable"; // 读形状就抛(已撤销的 Proxy 连 `Array.isArray` 都抛、`getPrototypeOf` 陷阱抛):不是「不朴素」,是读不出 —— 读者按最严处置
39
+ }
40
+ }
41
+ /**
42
+ * `Array.isArray` 的**总**版(不抛):已撤销的 Proxy 连「是不是数组」都读不出 ⇒ 不是数组。读者要问「这个值是不是数组」且值可能来自嵌入方(非 JSON)时调它,不直接 `Array.isArray`。
43
+ * 分类器闭包里不能 import,`./hooks-config` 的 `hookIsArray` 与本函数**逐字同文**(同一个门证明,与 `hookIsRecord` 同律)。
44
+ */
45
+ export function isJsonArray(v) {
46
+ try {
47
+ return Array.isArray(v);
48
+ }
49
+ catch {
50
+ return false; // 已撤销的 Proxy:读不出 ⇒ 不是数组
51
+ }
52
+ }
@@ -0,0 +1,31 @@
1
+ /** The domains a single-machine TOC carries: what `FileConfigStore` reads out of `config.d/`, and the
2
+ * **default**域集 of the bundle helpers (`exportBundle`/`readBundle`). 每个被排除的域必须在
3
+ * {@link NON_PORTABLE_DOMAIN_REASONS} 里有一条成文理由(禁裸排除,#282 件5 附带裁定)。
4
+ *
5
+ * ⚠️ 口径诚实(对抗复审 复审 round1 F4):这是**默认集与本地读取集,不是 API 级硬边界** —— `exportBundle`
6
+ * / `readBundle` 的 `domains` 参数可显式传任意 `DomainName`(bundle.ts 不拒非便携域)。把非便携域搬进
7
+ * bundle 的那条口子归调用方纪律(以及各自域的导出面),不是本常量能挡住的;要不要把 bundle API 收窄到
8
+ * `PortableDomain` 是一个独立的行为面裁定(#282 未决项,需三问),本批不擅自改。
9
+ *
10
+ * 🔴 **开新顶层域时必须同步登记本表**(成案 = 消费方 #322 真事故):域表是**闭集**,不在这里的
11
+ * 域名,`config.d/<域>.json` 不会被读进 effective —— 消费方拿到的是「该域缺席」,和文件不存在**逐字
12
+ * 同形**。当年 `limits` 域就是这样在单机腿静默丢了一整片限额发布(远端腿同批可用,所以更难看出来)。
13
+ * 今日的兜底:漏登记的文件会被 `FileConfigStore`(`./node`)的 `"unread-config-file"` 警告点名(fail-loud,
14
+ * 见 getEffective),但那只在消费方接了 `onWarning` 时才响 —— **登记仍是第一现场的义务**。 */
15
+ export declare const PORTABLE_DOMAINS: readonly ["models", "rosters", "skills", "mcp", "plugins", "scenarios", "collab", "governance", "execution", "prompts", "limits"];
16
+ export type PortableDomain = (typeof PORTABLE_DOMAINS)[number];
17
+ /**
18
+ * 非便携域的**排除理由表**(#282 件5 附带裁定:禁裸排除)。
19
+ *
20
+ * `satisfies Record<Exclude<DomainName, PortableDomain>, string>` 让键集在**编译期**双向咬死:新开一个
21
+ * 域却不放进 PORTABLE_DOMAINS ⇒ 这里缺行 ⇒ 编译错;把某域改成便携却忘了删行 ⇒ 多余键 ⇒ 编译错。
22
+ * (此前是「理由写在注释里」,机器只能做子串匹配——域名出现在文件任何角落都算数,等于没门;对抗复审
23
+ * 复审 round1 F3。)理由要写**为什么它不该被单机/跨部署搬运**,带出处。
24
+ */
25
+ export declare const NON_PORTABLE_DOMAIN_REASONS: {
26
+ readonly workers: "FLEET-only:单机没有 reconciler,`{workers:[]}` 的 schema 默认就是正确语义(缺席不需要 stub)。";
27
+ readonly hosts: "FLEET-only:同 workers —— 单机无机器注册表/自动排布,空注册表即正确语义。";
28
+ readonly systems: "FLEET-only:系统接入/凭据下发的元数据(nonce+路由)属于 web-admin 侧生命周期,单机 lane 无对应执法点。";
29
+ readonly projects: "TENANT IDENTITY DATA,不是可搬运的配置:登记簿随包复制进另一个部署 = 凭空多出的身份登记(design/142 §1.4 威胁模型①「carried-in foreign projectId」换了身 import 的衣服)。要带走走 142 §5 的导出面(service 半场)。";
30
+ readonly entitlement: "per-PRINCIPAL 商务/治理数据(tier·预算上限·kill-switch·allowlist),与 projects 同威胁模型(#282 件5 附带裁定,此前是唯一裸排除项):(a) 搬走 = 把一个租户的商务档复制进另一部署;(b) 反向也不成立 —— 单机 TOC 无 principal 轴(无鉴权语境、无 `/effective?principal=` 解析面),本地 entitlement.json 只会造出一份**永不被执法**的假管控面(真执法点在 service / web-admin 边界)。缺席语义如实:本地 buildEffective 给它 schema 默认值(空表),这**不等于**把远端 kill-switch/预算上限搬到了本地 —— 单机 lane 本就没有那个执法点。";
31
+ };
@@ -0,0 +1,30 @@
1
+ /** The domains a single-machine TOC carries: what `FileConfigStore` reads out of `config.d/`, and the
2
+ * **default**域集 of the bundle helpers (`exportBundle`/`readBundle`). 每个被排除的域必须在
3
+ * {@link NON_PORTABLE_DOMAIN_REASONS} 里有一条成文理由(禁裸排除,#282 件5 附带裁定)。
4
+ *
5
+ * ⚠️ 口径诚实(对抗复审 复审 round1 F4):这是**默认集与本地读取集,不是 API 级硬边界** —— `exportBundle`
6
+ * / `readBundle` 的 `domains` 参数可显式传任意 `DomainName`(bundle.ts 不拒非便携域)。把非便携域搬进
7
+ * bundle 的那条口子归调用方纪律(以及各自域的导出面),不是本常量能挡住的;要不要把 bundle API 收窄到
8
+ * `PortableDomain` 是一个独立的行为面裁定(#282 未决项,需三问),本批不擅自改。
9
+ *
10
+ * 🔴 **开新顶层域时必须同步登记本表**(成案 = 消费方 #322 真事故):域表是**闭集**,不在这里的
11
+ * 域名,`config.d/<域>.json` 不会被读进 effective —— 消费方拿到的是「该域缺席」,和文件不存在**逐字
12
+ * 同形**。当年 `limits` 域就是这样在单机腿静默丢了一整片限额发布(远端腿同批可用,所以更难看出来)。
13
+ * 今日的兜底:漏登记的文件会被 `FileConfigStore`(`./node`)的 `"unread-config-file"` 警告点名(fail-loud,
14
+ * 见 getEffective),但那只在消费方接了 `onWarning` 时才响 —— **登记仍是第一现场的义务**。 */
15
+ export const PORTABLE_DOMAINS = ["models", "rosters", "skills", "mcp", "plugins", "scenarios", "collab", "governance", "execution", "prompts", "limits"];
16
+ /**
17
+ * 非便携域的**排除理由表**(#282 件5 附带裁定:禁裸排除)。
18
+ *
19
+ * `satisfies Record<Exclude<DomainName, PortableDomain>, string>` 让键集在**编译期**双向咬死:新开一个
20
+ * 域却不放进 PORTABLE_DOMAINS ⇒ 这里缺行 ⇒ 编译错;把某域改成便携却忘了删行 ⇒ 多余键 ⇒ 编译错。
21
+ * (此前是「理由写在注释里」,机器只能做子串匹配——域名出现在文件任何角落都算数,等于没门;对抗复审
22
+ * 复审 round1 F3。)理由要写**为什么它不该被单机/跨部署搬运**,带出处。
23
+ */
24
+ export const NON_PORTABLE_DOMAIN_REASONS = {
25
+ workers: "FLEET-only:单机没有 reconciler,`{workers:[]}` 的 schema 默认就是正确语义(缺席不需要 stub)。",
26
+ hosts: "FLEET-only:同 workers —— 单机无机器注册表/自动排布,空注册表即正确语义。",
27
+ systems: "FLEET-only:系统接入/凭据下发的元数据(nonce+路由)属于 web-admin 侧生命周期,单机 lane 无对应执法点。",
28
+ projects: "TENANT IDENTITY DATA,不是可搬运的配置:登记簿随包复制进另一个部署 = 凭空多出的身份登记(design/142 §1.4 威胁模型①「carried-in foreign projectId」换了身 import 的衣服)。要带走走 142 §5 的导出面(service 半场)。",
29
+ entitlement: "per-PRINCIPAL 商务/治理数据(tier·预算上限·kill-switch·allowlist),与 projects 同威胁模型(#282 件5 附带裁定,此前是唯一裸排除项):(a) 搬走 = 把一个租户的商务档复制进另一部署;(b) 反向也不成立 —— 单机 TOC 无 principal 轴(无鉴权语境、无 `/effective?principal=` 解析面),本地 entitlement.json 只会造出一份**永不被执法**的假管控面(真执法点在 service / web-admin 边界)。缺席语义如实:本地 buildEffective 给它 schema 默认值(空表),这**不等于**把远端 kill-switch/预算上限搬到了本地 —— 单机 lane 本就没有那个执法点。",
30
+ };
@@ -4,8 +4,12 @@
4
4
  * law. This formalizes settings-core's v2-design §3 merge-spec into a published-data contract; the SEMANTIC
5
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, web-admin's
8
- * config-field→category map); THIS file is only the shared category spec they all cite — no third copy of the law.
7
+ * import). The settings-document UNIT table — which keys of a settings file carry a merge contract, where each sits,
8
+ * which category below it maps into, and how its raw value reads — is published by this package too, as
9
+ * `SETTINGS_UNITS` (`./settings-units.ts`, 14.6.0): a consumer reads it instead of keeping a key→category map of its
10
+ * own for those ten units (the http-hook allowlist keys are the one exception: `./http-hook-allowlists` keys and reads them
11
+ * itself, with its own malformed-value reading — see the `./settings-units` header). THIS file is the category spec every unit
12
+ * cites — no second copy of the law.
9
13
  *
10
14
  * Per-category behavior is fully determined by `mergeShape` + `minTrust` + `tighten` + `enforcement` + `fail` — and,
11
15
  * for a MEMBERIZED category (one that declares `members`, 1.1.0), by its member rows: each member's own floor and
@@ -14,6 +18,16 @@
14
18
  * admitted or rejected as one value under its `minTrust`. A resolver reads this table and has NO per-key
15
19
  * special-casing.
16
20
  *
21
+ * COMPARISON (14.6.0) — "which of two values is stricter" has ONE implementation, {@link tightenVerdict}, and it reads
22
+ * ORDER only, never spelling (an empty-set sentinel, a "≤ 0 means off" cap, CC's `"disable"`, a mode alias are the value
23
+ * owner's reading — `readSettingsUnit` for a settings unit).
24
+ * A category's `tighten` answers ONLY the value of a `whole` unit mapped into it (today: `permissionMode`'s enumRank,
25
+ * `additionalDirectories`' setNarrower, `none` for the rest).
26
+ * A DECLARED member's comparator is derived from its row's value class ({@link memberTighten}: `boolPole` ⇒ boolStricter
27
+ * at that pole; `list` ⇒ setUnion for a tighten member, setNarrower for a loosen one). A memberized category's whole value
28
+ * and its UNDECLARED members have NO comparator — they only have the category floor's admission (the paragraph above);
29
+ * asking for one is a RangeError, never a fallback to the category rule.
30
+ *
17
31
  * MEMBER-LEVEL ADMISSION (1.1.0 — the WRITTEN layer of the permission-rule dual-channel ruling, [ref] ④;
18
32
  * the executing layer is core's `prepareCcImport` door + `RuleAddOrigin`, the resolver/shell halves are cli's):
19
33
  * A category may declare `members`, each with its own admission floor and DIRECTION. Today only `permissions`
@@ -53,8 +67,8 @@
53
67
  import { z } from "zod";
54
68
  /** How values from different layers/principals combine for a category. */
55
69
  export declare const MergeShape: z.ZodEnum<{
56
- concat: "concat";
57
70
  replace: "replace";
71
+ concat: "concat";
58
72
  denyFirst: "denyFirst";
59
73
  runAll: "runAll";
60
74
  overrideByName: "overrideByName";
@@ -121,7 +135,14 @@ export type MemberDirection = z.infer<typeof MemberDirection>;
121
135
  * must be the LOOSEN floor (the category's), so a consumer that predates this field (its schema strips
122
136
  * `boolPole`) reads the row as tighten@<loosen-floor> and withholds BOTH poles from low-trust layers —
123
137
  * fail-closed (it loses only the convenience of low-trust tightening, it can never admit a loosening write).
124
- * Resolution is value-aware: pass the candidate value to {@link memberAdmission} / {@link memberMinTrust}. */
138
+ * Resolution is value-aware: pass the candidate value to {@link memberAdmission} / {@link memberMinTrust}.
139
+ *
140
+ * `list` (14.6.0) — the VALUE-CLASS seat of a member whose value is a string list (`permissions.allow` / `deny` / `ask`,
141
+ * `sandboxNetwork.filesystem.denyWrite` / `denyRead`). A row declares exactly ONE value class — `boolPole` (a boolean)
142
+ * or `list` — and the member's comparator is derived from it ({@link resolveMemberTighten}); neither or both is a
143
+ * RangeError there, never a guess (a future numeric member is neither, so it can never be silently read as a list —
144
+ * its value class has to be designed first). The seat carries no floor, so admission reads exactly as before, and a
145
+ * consumer whose schema predates it strips it. */
125
146
  export declare const MemberAdmission: z.ZodObject<{
126
147
  direction: z.ZodEnum<{
127
148
  loosen: "loosen";
@@ -146,6 +167,7 @@ export declare const MemberAdmission: z.ZodObject<{
146
167
  task: "task";
147
168
  }>;
148
169
  }, z.core.$strip>>;
170
+ list: z.ZodOptional<z.ZodLiteral<true>>;
149
171
  }, z.core.$strip>;
150
172
  export type MemberAdmission = z.infer<typeof MemberAdmission>;
151
173
  /** The RESOLVED admission a consumer branches on — the category fallback already applied, so `minTrust` is never
@@ -180,7 +202,12 @@ export type FailMode = z.infer<typeof FailMode>;
180
202
  * from LOOSEST→STRICTEST — the category's whole vocabulary: a word outside it is refused, and a ONE-word order (11.0.0:
181
203
  * `shellGate`, whose engine vocabulary is the single floor word) is the degenerate rank where the only merge is identity;
182
204
  * `boolStricter.strict` is the bool value that is the tighter one; `setNarrower` = a subset is tighter; `policyDenyWins` =
183
- * deny beats allow; `none` = not rank-tightenable (replace / conflict-reject). */
205
+ * deny beats allow (a fold law BETWEEN a category's members, not an order on one value); `none` = not rank-tightenable
206
+ * (replace / conflict-reject); `setUnion` (14.6.0) = a superset is tighter; `numericLower` (14.6.0) = a smaller number is
207
+ * tighter, `+Infinity` (no limit) the loosest. A parameter may only change the ORDER (`strict`, `order`), so the two
208
+ * appended words take none: a literal that reads as "empty set" or "no limit" is a spelling, normalized by the value's
209
+ * owner before it reaches the comparator. Executed by {@link tightenVerdict}. The two words are appended at the tail
210
+ * (README 发版纪律: a consumer holding an exhaustive table over `rule` goes compile-red at pickup — the pickup cost). */
184
211
  export declare const TightenRule: z.ZodDiscriminatedUnion<[z.ZodObject<{
185
212
  rule: z.ZodLiteral<"none">;
186
213
  }, z.core.$strip>, z.ZodObject<{
@@ -193,12 +220,43 @@ export declare const TightenRule: z.ZodDiscriminatedUnion<[z.ZodObject<{
193
220
  }, z.core.$strip>, z.ZodObject<{
194
221
  rule: z.ZodLiteral<"enumRank">;
195
222
  order: z.ZodArray<z.ZodString>;
223
+ }, z.core.$strip>, z.ZodObject<{
224
+ rule: z.ZodLiteral<"setUnion">;
225
+ }, z.core.$strip>, z.ZodObject<{
226
+ rule: z.ZodLiteral<"numericLower">;
196
227
  }, z.core.$strip>], "rule">;
197
228
  export type TightenRule = z.infer<typeof TightenRule>;
229
+ /** Where `candidate` lands relative to `existing` under a {@link TightenRule} (14.6.0; closed, in this order):
230
+ * `equal` · `stricter` · `looser` · `unordered` (comparable values, neither side contains the other — two different sets,
231
+ * two different `none` values) · `unreadable` (a side is outside the rule's normalized domain — absent, misspelled,
232
+ * the wrong type). */
233
+ export declare const TIGHTEN_VERDICTS: readonly ["equal", "stricter", "looser", "unordered", "unreadable"];
234
+ export type TightenVerdict = (typeof TIGHTEN_VERDICTS)[number];
235
+ /**
236
+ * THE comparator (14.6.0): where `candidate` lands relative to `existing` under `rule`. One implementation for every
237
+ * consumer — the shell's resolver, the server's catalog column, the profile loader — so "stricter" has one reading.
238
+ * It reads ORDER only: each rule has a normalized domain, and a side outside it is `unreadable` (spellings are normalized
239
+ * by the value's owner first — `readSettingsUnit` for a settings unit):
240
+ * · `enumRank` — a word of `order`; the higher rank is stricter, the same word `equal`;
241
+ * · `boolStricter` — a boolean (strict equality, never truthiness: the string `"false"` is unreadable, not `true`);
242
+ * the `strict` pole is stricter;
243
+ * · `setUnion` — an array of strings, as a SET (order and duplicates do not count): a proper superset is stricter,
244
+ * a proper subset looser, the same set `equal`, neither containing the other `unordered`;
245
+ * · `setNarrower` — the same domain, the opposite direction;
246
+ * · `numericLower` — a finite number or `+Infinity` (no limit, the loosest): the smaller is stricter;
247
+ * · `none` — a strict JSON value no deeper than {@link MAX_HASH_DEPTH}: `equal` when `stableStringify` agrees
248
+ * (key order does not count), else `unordered`;
249
+ * · `policyDenyWins` — a RangeError: it is the fold law BETWEEN a category's members, not an order on one value —
250
+ * compare a member through {@link memberTighten}.
251
+ * Both sides must be present: absence is not a value (`undefined` ⇒ `unreadable`); "an absent landing spot takes the
252
+ * write" is the loading law's business, not the comparator's. A rule word outside the closed set (a JavaScript /
253
+ * deserialized caller) is a RangeError, never a silent verdict.
254
+ */
255
+ export declare function tightenVerdict(rule: TightenRule, existing: unknown, candidate: unknown): TightenVerdict;
198
256
  export declare const MergeCategorySpec: z.ZodObject<{
199
257
  mergeShape: z.ZodEnum<{
200
- concat: "concat";
201
258
  replace: "replace";
259
+ concat: "concat";
202
260
  denyFirst: "denyFirst";
203
261
  runAll: "runAll";
204
262
  overrideByName: "overrideByName";
@@ -223,6 +281,10 @@ export declare const MergeCategorySpec: z.ZodObject<{
223
281
  }, z.core.$strip>, z.ZodObject<{
224
282
  rule: z.ZodLiteral<"enumRank">;
225
283
  order: z.ZodArray<z.ZodString>;
284
+ }, z.core.$strip>, z.ZodObject<{
285
+ rule: z.ZodLiteral<"setUnion">;
286
+ }, z.core.$strip>, z.ZodObject<{
287
+ rule: z.ZodLiteral<"numericLower">;
226
288
  }, z.core.$strip>], "rule">;
227
289
  enforcement: z.ZodEnum<{
228
290
  gate: "gate";
@@ -268,6 +330,7 @@ export declare const MergeCategorySpec: z.ZodObject<{
268
330
  task: "task";
269
331
  }>;
270
332
  }, z.core.$strip>>;
333
+ list: z.ZodOptional<z.ZodLiteral<true>>;
271
334
  }, z.core.$strip>>>;
272
335
  }, z.core.$strip>;
273
336
  export type MergeCategorySpec = z.infer<typeof MergeCategorySpec>;
@@ -288,14 +351,17 @@ export declare const SAFETY_MERGE_SPEC: {
288
351
  readonly allow: {
289
352
  readonly direction: "loosen";
290
353
  readonly minTrust: "global";
354
+ readonly list: true;
291
355
  };
292
356
  readonly deny: {
293
357
  readonly direction: "tighten";
294
358
  readonly minTrust: "local";
359
+ readonly list: true;
295
360
  };
296
361
  readonly ask: {
297
362
  readonly direction: "tighten";
298
363
  readonly minTrust: "local";
364
+ readonly list: true;
299
365
  };
300
366
  };
301
367
  };
@@ -429,10 +495,12 @@ export declare const SAFETY_MERGE_SPEC: {
429
495
  readonly "filesystem.denyWrite": {
430
496
  readonly direction: "tighten";
431
497
  readonly minTrust: "local";
498
+ readonly list: true;
432
499
  };
433
500
  readonly "filesystem.denyRead": {
434
501
  readonly direction: "tighten";
435
502
  readonly minTrust: "local";
503
+ readonly list: true;
436
504
  };
437
505
  };
438
506
  };
@@ -608,3 +676,17 @@ export declare function memberAdmission<C extends SafetyMergeCategory>(category:
608
676
  /** A member's admission FLOOR from the published table — the word a resolver compares the layer's trust against
609
677
  * (`isAtLeast(layer, floor)` ⇒ admitted; the ladder is {@link trustRank}). Convenience over {@link memberAdmission}. */
610
678
  export declare function memberMinTrust<C extends SafetyMergeCategory>(category: C, member: SafetyMergeMember<C>, value?: unknown): MinTrust;
679
+ /**
680
+ * Derive one member's comparator off a category spec VALUE (14.6.0; the single implementation — {@link memberTighten} binds
681
+ * it to {@link SAFETY_MERGE_SPEC}). The comparator follows from the row's VALUE CLASS and direction, never from the category's
682
+ * `tighten` (that answers only a `whole` unit's value — the file header):
683
+ * · `boolPole` ⇒ `{ rule: "boolStricter", strict: boolPole.strict }`;
684
+ * · `list` ⇒ `setUnion` for a `tighten` member (a superset narrows more), `setNarrower` for a `loosen` member (a subset grants less).
685
+ * A row declaring NEITHER value class or BOTH is a RangeError (nothing to derive / two readings) — never a default; so are the
686
+ * misuse arms {@link resolveMemberAdmission} has (a whole-key category, an undeclared member).
687
+ * @param categoryLabel only decorates the error message.
688
+ */
689
+ export declare function resolveMemberTighten(spec: MergeCategorySpec, member: string, categoryLabel?: string): TightenRule;
690
+ /** A declared member's comparator from the published table (see {@link resolveMemberTighten}); compare two of its values with
691
+ * {@link tightenVerdict}. Unknown category or member ⇒ RangeError (never the category rule as a fallback). */
692
+ export declare function memberTighten<C extends SafetyMergeCategory>(category: C, member: SafetyMergeMember<C>): TightenRule;