@passioncode-ai/fabric-agent-adapter 0.79.1 → 0.81.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 (120) hide show
  1. package/CHANGELOG.md +213 -0
  2. package/README.md +13 -2
  3. package/SECURITY.md +23 -8
  4. package/bin/fabric-agent-adapter.js +50 -5
  5. package/package.json +1 -1
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +4 -4
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +12 -4
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +1 -1
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +44 -7
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +107 -2
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +26 -6
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +9 -1
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +24 -5
  16. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +57 -76
  17. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_restart.py +56 -0
  18. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +912 -0
  19. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/new_agent.py +308 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +8 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/README.md +67 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/dashboard_service.py +252 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.mjs +168 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.py +179 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/README.md +57 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.mjs +181 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.py +237 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +1 -1
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +4 -4
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -2
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +12 -4
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +7 -3
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +12 -1
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +54 -3
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +6 -3
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +33 -12
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -0
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +10 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +25 -7
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +44 -3
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +21 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +26 -6
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +24 -33
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +12 -6
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +136 -3
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +3 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +1 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +37 -5
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +118 -3
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +266 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +72 -7
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -2
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-load.mjs +344 -0
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +98 -1
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +7 -3
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +10 -3
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +11 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +114 -20
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +8 -1
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +3 -2
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +2 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +97 -3
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +50 -8
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +120 -3
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +316 -0
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +40 -3
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +15 -2
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +2 -2
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_load.py +358 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +103 -1
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +28 -13
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +24 -5
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +23 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +110 -9
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +12 -1
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +469 -29
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +4 -1
  80. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +18 -6
  81. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +44 -0
  82. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +4 -4
  83. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +19 -328
  84. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +12 -4
  85. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +1 -1
  86. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/new-agent.md +42 -0
  87. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +7 -1
  88. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +44 -7
  89. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +28 -0
  90. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/working-rules.md +54 -0
  91. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/access.schema.json +50 -0
  92. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/connections.schema.json +23 -0
  93. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/data-rooms.schema.json +74 -0
  94. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/manifest.schema.json +170 -0
  95. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/template-header.schema.json +25 -0
  96. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +26 -6
  97. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/template_check.py +491 -0
  98. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/CHANGELOG.md +7 -0
  99. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/TEMPLATE.md +347 -0
  100. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/access.json +24 -0
  101. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/assembly.md +15 -0
  102. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/checks/acceptance.md +17 -0
  103. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/connections.json +29 -0
  104. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/data-rooms.json +9 -0
  105. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/manifest.json +32 -0
  106. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  107. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +21 -21
  108. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +4 -4
  109. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/cleanup.md +30 -0
  110. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +2 -1
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +3 -2
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/remote-sessions.md +75 -0
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +28 -13
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/task-context.md +58 -0
  115. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/waits.md +36 -0
  116. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +14 -0
  117. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +143 -36
  118. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +136 -15
  119. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +78 -3
  120. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +9 -2
@@ -0,0 +1,358 @@
1
+ """The call queue and the load report of DEC-0104 (contract `src/agent-network.ts`, FAC-SEM-131 to FAC-SEM-133), as the
2
+ contract's reference answers them: a node that hosts a job-backed agent queues its jobs per (agentRef, context) with
3
+ these and reports its load as `service-load/1` under `extensions/load/0.1`. Part of the service kit (FAA-289, layer L3);
4
+ the twin of fabric-load.mjs. Standard library only. Nothing here raises on what it reads, except `load_extension`, which
5
+ refuses a declaration a reader would treat as not declared.
6
+
7
+ - job_caller_key(accepted, credential): the opaque key of a job's caller (an accepted delegation's principal, its
8
+ outermost actor and a partner organization ending the chain; else the credential the node knows the caller by).
9
+ - next_queued_job(queue, running, limits): the job the node starts next, or None (fairness between callers).
10
+ - admit_job(queue, running, job, limits, retry_after_seconds): {admit: True}, or the `queue-full` refusal with its data.
11
+ - queue_status_message(position, length), read_queue_position(status_message): `queued <position> of <length>`.
12
+ - job_queue_rules(value): FAC-SEM-131 over {queue, running, limits, started}.
13
+ - load_report_rules(report): FAC-SEM-132 on a `service-load/1` report; load_report_problems(report): its schema.
14
+ - load_view(well_known), load_extension_rules(well_known): the declaration as a reader reads it, and FAC-SEM-133.
15
+ - load_extension(path, window_seconds, limits): the `extensions` entry a service declares.
16
+
17
+ The kit has no JSON Schema validator: the two shapes are written here from the schemas as the contract's validator
18
+ reads them (ECMAScript's \\s in patterns, lengths in code points, `date-time` as ajv-formats' full mode). Times in the
19
+ queue are UTC instants (`interop-common#/$defs/utcInstant`); times in the report are RFC 3339 read strictly (the
20
+ contract's strictTime). The wording of a schema problem is the kit's, not ajv's."""
21
+ from __future__ import annotations
22
+
23
+ import math
24
+ import re
25
+ from typing import Any, Dict, List, Optional
26
+
27
+ import sys as _sys # noqa: E402
28
+ from pathlib import Path as _Path # noqa: E402
29
+ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
30
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
31
+
32
+ from fabric_base import ServiceError # noqa: E402
33
+ from fabric_connections import date_time # noqa: E402 - ajv-formats' date-time, as the contract's validator reads it
34
+ from fabric_jsvalue import ( # noqa: E402
35
+ MISSING, _JS_SPACE, double, get, is_number, json_stringify, objects, safe_integer, same, show,
36
+ )
37
+ from fabric_time import parse_rfc3339_ms # noqa: E402 - the contract's strictTime
38
+
39
+ LOAD_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/load/0.1"
40
+ LOAD_REPORT_TYPE = "service-load/1"
41
+ #: The refusal code, the same as fabric_interop_rules.QUEUE_FULL (a test holds them equal); not imported because the
42
+ #: facade fabric_service, which re-exports this module, comes before the interop rules in the kit's order.
43
+ _QUEUE_FULL = "queue-full"
44
+ QUEUE_FULL_MESSAGE = "the agent's queue for this context is full: start again later"
45
+ #: The longest queue a status message names (`queued <position> of <length>`, six digits).
46
+ QUEUE_LENGTH_MAX = 999999
47
+ DEFAULT_RETRY_AFTER_SECONDS = 60
48
+
49
+ _UTC_INSTANT = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-5][0-9](?:\.[0-9]{1,3})?Z", re.ASCII)
50
+ _QUEUED = re.compile(r"queued ([1-9][0-9]{0,5}) of ([1-9][0-9]{0,5})", re.ASCII)
51
+ _ORIGIN_PATH = re.compile("/(?!/)[^%s]*" % re.escape(_JS_SPACE))
52
+ _CONTEXT = re.compile(r"(?:personal|org:[A-Za-z0-9][A-Za-z0-9_-]{0,127})", re.ASCII)
53
+ _KEY_PATH = "/extensions/" + LOAD_EXTENSION_KEY.replace("~", "~0").replace("/", "~1")
54
+ _LIMITS = (("concurrency", 1, 1024), ("perCaller", 1, 1024), ("queueMax", 0, 100000))
55
+ _ROW_COUNTS = ("queued", "running", "completed", "failed")
56
+
57
+
58
+ def _utc_ms(value: Any) -> float:
59
+ """A UTC instant (`T`, `Z`, at most three fraction digits, a day that exists) in milliseconds, else NaN."""
60
+ return parse_rfc3339_ms(value) if isinstance(value, str) and _UTC_INSTANT.fullmatch(value) else math.nan
61
+
62
+
63
+ def _strict_ms(value: Any) -> float:
64
+ return parse_rfc3339_ms(value) if isinstance(value, str) else math.nan
65
+
66
+
67
+ def _utf16(value: str) -> bytes:
68
+ """A key that orders strings as JavaScript's `<` does: by UTF-16 code units."""
69
+ return value.encode("utf-16-be", "surrogatepass")
70
+
71
+
72
+ def _string(value: Any) -> bool:
73
+ return isinstance(value, str)
74
+
75
+
76
+ # --- The call queue (fabric-interop, DEC-0104) ---
77
+
78
+
79
+ def job_caller_key(accepted: Any = MISSING, credential: Any = MISSING) -> Optional[str]:
80
+ """The key of a job's caller at a node that queues (DEC-0104), as the contract's jobCallerKey: for a call whose
81
+ delegation the node accepted (verify_delegation's answer), its `principal` and `caller` and the partner
82
+ organization when the chain ends in one (DEC-0090); for a call with no token, the credential the node knows the
83
+ caller by (a gate's pass with `principal: None` is that case too). A refused verdict has no key, and neither has
84
+ an unreadable call: None. The key is opaque; only equality matters."""
85
+ if isinstance(accepted, dict) and not (get(accepted, "ok") is True and get(accepted, "principal") is None):
86
+ principal, caller = get(accepted, "principal"), get(accepted, "caller")
87
+ if get(accepted, "ok") is not True or not _string(principal) or principal == "" or not _string(caller) or caller == "":
88
+ return None
89
+ chain = get(accepted, "chain")
90
+ chain = chain if isinstance(chain, list) else []
91
+ last = chain[-1] if chain else MISSING
92
+ partner = [last] if len(chain) > 1 and _string(last) and last.startswith("org:") else []
93
+ return json_stringify(["delegated", principal, caller] + partner)
94
+ return json_stringify(["credential", credential]) if _string(credential) and credential != "" else None
95
+
96
+
97
+ def _limits_ok(limits: Any) -> bool:
98
+ if not isinstance(limits, dict):
99
+ return False
100
+ concurrency, per_caller, queue_max = get(limits, "concurrency"), get(limits, "perCaller"), get(limits, "queueMax")
101
+ return (all(safe_integer(n) and double(n) >= 1 for n in (concurrency, per_caller))
102
+ and safe_integer(queue_max) and double(queue_max) >= 0)
103
+
104
+
105
+ def _waiting_job(job: Any) -> bool:
106
+ return isinstance(job, dict) and _string(get(job, "id")) and _string(get(job, "caller")) and math.isfinite(_utc_ms(get(job, "at")))
107
+
108
+
109
+ def next_queued_job(queue: Any, running: Any, limits: Any) -> Optional[str]:
110
+ """The job the hosting node starts next for one (agentRef, context), or None (DEC-0104). Nothing starts while
111
+ `concurrency` jobs run (every running job holds a slot, its caller readable or not). Of the waiting jobs whose
112
+ caller runs fewer than `perCaller`, the one whose caller runs the fewest wins; between those callers, the one whose
113
+ oldest waiting job arrived first; within a caller, its oldest. Arrival ties break by id (UTF-16 order)."""
114
+ if not _limits_ok(limits):
115
+ return None
116
+ if (len(running) if isinstance(running, list) else 0) >= double(limits["concurrency"]):
117
+ return None
118
+ running_of: Dict[str, int] = {}
119
+ for job in objects(running):
120
+ if _string(get(job, "caller")):
121
+ running_of[job["caller"]] = running_of.get(job["caller"], 0) + 1
122
+ waiting = [job for job in objects(queue) if _waiting_job(job) and running_of.get(job["caller"], 0) < double(limits["perCaller"])]
123
+ waiting.sort(key=lambda job: (_utc_ms(job["at"]), _utf16(job["id"])))
124
+ best: Optional[Dict[str, Any]] = None
125
+ for job in waiting:
126
+ if best is None or running_of.get(job["caller"], 0) < running_of.get(best["caller"], 0):
127
+ best = job
128
+ return None if best is None else best["id"]
129
+
130
+
131
+ def admit_job(queue: Any, running: Any, job: Any, limits: Any, retry_after_seconds: Any = MISSING) -> Dict[str, Any]:
132
+ """Whether a start is admitted (DEC-0104): it is when the job would start at once (next_queued_job over the queue
133
+ with it added answers its id), or when fewer than `queueMax` jobs wait; otherwise {admit: False, error} with the
134
+ `queue-full` refusal and its `retryAfterSeconds` (1 to 3600, else 60). Unreadable limits or job refuse."""
135
+ waiting = objects(queue)
136
+ if _limits_ok(limits) and _waiting_job(job) and (
137
+ next_queued_job(waiting + [job], running, limits) == job["id"] or len(waiting) < double(limits["queueMax"])):
138
+ return {"admit": True}
139
+ after = retry_after_seconds if safe_integer(retry_after_seconds) and 1 <= double(retry_after_seconds) <= 3600 else DEFAULT_RETRY_AFTER_SECONDS
140
+ return {"admit": False, "error": {"code": _QUEUE_FULL, "message": QUEUE_FULL_MESSAGE,
141
+ "data": {"retryable": True, "retryAfterSeconds": int(after)}}}
142
+
143
+
144
+ def queue_status_message(position: Any, length: Any) -> Optional[str]:
145
+ """The `statusMessage` of a waiting job (DEC-0104): `queued <position> of <length>`, 1 is next, 1 <= position <=
146
+ length <= 999999; None for anything else."""
147
+ if not (safe_integer(position) and safe_integer(length)):
148
+ return None
149
+ if not 1 <= double(position) <= double(length) <= QUEUE_LENGTH_MAX:
150
+ return None
151
+ return "queued %d of %d" % (int(position), int(length))
152
+
153
+
154
+ def read_queue_position(status_message: Any) -> Optional[Dict[str, int]]:
155
+ """A waiting job's place, read from its `statusMessage`, or None when it is not the queue's grammar."""
156
+ match = _QUEUED.fullmatch(status_message) if _string(status_message) else None
157
+ if not match:
158
+ return None
159
+ position, length = int(match.group(1)), int(match.group(2))
160
+ return {"position": position, "length": length} if 1 <= position <= length else None
161
+
162
+
163
+ def _finding(code: str, instance_path: str, message: str) -> Dict[str, str]:
164
+ return {"code": code, "instancePath": instance_path, "message": message}
165
+
166
+
167
+ def job_queue_rules(value: Any) -> List[Dict[str, str]]:
168
+ """FAC-SEM-131 on {queue, running, limits, started} (`started`: the id the node started, or None): it is
169
+ next_queued_job's."""
170
+ want = next_queued_job(get(value, "queue"), get(value, "running"), get(value, "limits"))
171
+ started = get(value, "started")
172
+ if same(None if started is MISSING else started, want):
173
+ return []
174
+ return [_finding("FAC-SEM-131", "/started", "nothing may start now" if want is None else "the job to start is %s" % show(want))]
175
+
176
+
177
+ # --- The load report (DEC-0104) ---
178
+
179
+
180
+ def load_report_rules(report: Any) -> List[Dict[str, str]]:
181
+ """FAC-SEM-132 on a `service-load/1` report: windows in order one `windowSeconds` apart, none after `generatedAt`,
182
+ one row per context, p50 at most p95, a run time only where a job ended."""
183
+ findings: List[Dict[str, str]] = []
184
+ flag = lambda path, message: findings.append(_finding("FAC-SEM-132", path, message)) # noqa: E731
185
+ seconds = get(report, "windowSeconds")
186
+ step = double(seconds) * 1000 if safe_integer(seconds) else math.nan
187
+ generated = _strict_ms(get(report, "generatedAt"))
188
+ previous: Any = MISSING
189
+ for index, window in enumerate(objects(get(report, "windows"))):
190
+ start = _strict_ms(get(window, "start"))
191
+ if start > generated:
192
+ flag("/windows/%d/start" % index, "a window starts after the report was generated")
193
+ if previous is not MISSING and start - previous != step:
194
+ flag("/windows/%d/start" % index, "windows are not in order one windowSeconds apart")
195
+ previous = start
196
+ seen = set()
197
+ for at, row in enumerate(objects(get(window, "byContext"))):
198
+ path = "/windows/%d/byContext/%d" % (index, at)
199
+ context = get(row, "context")
200
+ if _string(context) and context in seen:
201
+ flag(path + "/context", "context %s appears twice in one window" % show(context))
202
+ if _string(context):
203
+ seen.add(context)
204
+ for name in ("waitMs", "runMs"):
205
+ pct = get(row, name)
206
+ p50, p95 = get(pct, "p50"), get(pct, "p95")
207
+ if isinstance(pct, dict) and is_number(p50) and is_number(p95) and double(p50) > double(p95):
208
+ flag("%s/%s" % (path, name), "p50 is above p95")
209
+ if isinstance(get(row, "runMs"), dict) and same(get(row, "completed"), 0) and same(get(row, "failed"), 0):
210
+ flag(path + "/runMs", "a run time with no job ended in the window")
211
+ return findings
212
+
213
+
214
+ def _integer(value: Any, low: float, high: Optional[float], at: str, errors: List[str]) -> None:
215
+ """JSON Schema's integer with its bounds, as ajv reads it: a number with no fraction (2.0 is one), never a bool."""
216
+ if not is_number(value) or not math.isfinite(double(value)) or double(value) != math.floor(double(value)):
217
+ errors.append("%s type" % at)
218
+ elif double(value) < low:
219
+ errors.append("%s minimum" % at)
220
+ elif high is not None and double(value) > high:
221
+ errors.append("%s maximum" % at)
222
+
223
+
224
+ def _members(value: Dict[str, Any], allowed: tuple, required: tuple, at: str, errors: List[str]) -> None:
225
+ errors.extend("%s/%s additionalProperties" % (at, key) for key in value if key not in allowed)
226
+ errors.extend("%s required %s" % (at or "/", key) for key in required if key not in value)
227
+
228
+
229
+ def _timestamp(value: Any, at: str, errors: List[str]) -> None:
230
+ if not _string(value):
231
+ errors.append("%s type" % at)
232
+ elif not date_time(value):
233
+ errors.append("%s format" % at)
234
+
235
+
236
+ def _percentiles(value: Any, at: str, errors: List[str]) -> None:
237
+ if not isinstance(value, dict):
238
+ errors.append("%s type" % at)
239
+ return
240
+ _members(value, ("p50", "p95"), ("p50", "p95"), at, errors)
241
+ for name in ("p50", "p95"):
242
+ if name in value:
243
+ _integer(value[name], 0, None, "%s/%s" % (at, name), errors)
244
+
245
+
246
+ def _row_problems(row: Any, at: str, errors: List[str]) -> None:
247
+ if not isinstance(row, dict):
248
+ errors.append("%s type" % at)
249
+ return
250
+ _members(row, ("context",) + _ROW_COUNTS + ("waitMs", "runMs"), ("context",) + _ROW_COUNTS, at, errors)
251
+ if "context" in row and not (_string(row["context"]) and _CONTEXT.fullmatch(row["context"])):
252
+ errors.append("%s/context %s" % (at, "pattern" if _string(row["context"]) else "type"))
253
+ for name in _ROW_COUNTS:
254
+ if name in row:
255
+ _integer(row[name], 0, None, "%s/%s" % (at, name), errors)
256
+ for name in ("waitMs", "runMs"):
257
+ if name in row:
258
+ _percentiles(row[name], "%s/%s" % (at, name), errors)
259
+
260
+
261
+ def load_report_problems(report: Any) -> List[str]:
262
+ """service-load.schema.json: what makes `report` not a `service-load/1` report, each as `<path> <keyword>`; empty
263
+ for a valid one. Counts and durations only: a member the schema does not name (a caller, a job id) is refused."""
264
+ if not isinstance(report, dict):
265
+ return ["/ type"]
266
+ errors: List[str] = []
267
+ _members(report, ("type", "generatedAt", "windowSeconds", "windows"), ("type", "generatedAt", "windowSeconds", "windows"), "", errors)
268
+ if "type" in report and not same(report["type"], LOAD_REPORT_TYPE):
269
+ errors.append("/type const")
270
+ if "generatedAt" in report:
271
+ _timestamp(report["generatedAt"], "/generatedAt", errors)
272
+ if "windowSeconds" in report:
273
+ _integer(report["windowSeconds"], 60, 86400, "/windowSeconds", errors)
274
+ windows = report.get("windows", MISSING)
275
+ if windows is not MISSING and not isinstance(windows, list):
276
+ errors.append("/windows type")
277
+ elif isinstance(windows, list):
278
+ if len(windows) > 168:
279
+ errors.append("/windows maxItems")
280
+ for index, window in enumerate(windows):
281
+ at = "/windows/%d" % index
282
+ if not isinstance(window, dict):
283
+ errors.append("%s type" % at)
284
+ continue
285
+ _members(window, ("start", "byContext"), ("start", "byContext"), at, errors)
286
+ if "start" in window:
287
+ _timestamp(window["start"], at + "/start", errors)
288
+ rows = window.get("byContext", MISSING)
289
+ if rows is not MISSING and not isinstance(rows, list):
290
+ errors.append("%s/byContext type" % at)
291
+ elif isinstance(rows, list):
292
+ if len(rows) > 64:
293
+ errors.append("%s/byContext maxItems" % at)
294
+ for row_index, row in enumerate(rows):
295
+ _row_problems(row, "%s/byContext/%d" % (at, row_index), errors)
296
+ return errors
297
+
298
+
299
+ def _declaration_problems(value: Any) -> List[str]:
300
+ """service-load-extension.schema.json, each problem as `<path> <keyword>`; empty for a valid declaration."""
301
+ if not isinstance(value, dict):
302
+ return ["/ type"]
303
+ errors: List[str] = []
304
+ _members(value, ("path", "windowSeconds", "limits"), ("path", "windowSeconds", "limits"), "", errors)
305
+ path = value.get("path", MISSING)
306
+ if path is not MISSING:
307
+ if not _string(path):
308
+ errors.append("/path type")
309
+ else:
310
+ if len(path) > 256: # code points, as ajv counts them; the originPath's own 2048 is wider
311
+ errors.append("/path maxLength")
312
+ if not _ORIGIN_PATH.fullmatch(path):
313
+ errors.append("/path pattern")
314
+ if "windowSeconds" in value:
315
+ _integer(value["windowSeconds"], 60, 86400, "/windowSeconds", errors)
316
+ limits = value.get("limits", MISSING)
317
+ if limits is not MISSING:
318
+ if not isinstance(limits, dict):
319
+ errors.append("/limits type")
320
+ else:
321
+ _members(limits, tuple(name for name, _, _ in _LIMITS), tuple(name for name, _, _ in _LIMITS), "/limits", errors)
322
+ for name, low, high in _LIMITS:
323
+ if name in limits:
324
+ _integer(limits[name], low, high, "/limits/%s" % name, errors)
325
+ return errors
326
+
327
+
328
+ def load_view(well_known: Any) -> Dict[str, Any]:
329
+ """The load declaration as a reader reads it (DEC-0104): `declared` when the extension key is present, `valid` when
330
+ its value is the schema's, `load` the declaration when valid (else None) and `errors` its problems. A reader treats
331
+ an invalid value as not declared and reports it (FAC-SEM-133)."""
332
+ extensions = get(well_known, "extensions")
333
+ if not isinstance(extensions, dict) or LOAD_EXTENSION_KEY not in extensions:
334
+ return {"declared": False, "valid": False, "load": None, "errors": []}
335
+ value = extensions[LOAD_EXTENSION_KEY]
336
+ errors = _declaration_problems(value)
337
+ return {"declared": True, "valid": not errors, "load": None if errors else value, "errors": errors}
338
+
339
+
340
+ def load_extension_rules(well_known: Any) -> List[Dict[str, str]]:
341
+ """FAC-SEM-133 on a well-known document: a value under the load extension key is valid against
342
+ service-load-extension.schema.json; one finding at the key otherwise."""
343
+ view = load_view(well_known)
344
+ if not view["declared"] or view["valid"]:
345
+ return []
346
+ return [_finding("FAC-SEM-133", _KEY_PATH, "the load declaration is invalid (%s); a reader treats it as not declared "
347
+ "and reports it (DEC-0104)" % "; ".join(view["errors"]))]
348
+
349
+
350
+ def load_extension(path: Any, window_seconds: Any, limits: Any) -> Dict[str, Any]:
351
+ """The `extensions` entry a service declares (DEC-0104): where it serves its `service-load/1` report (an origin
352
+ path, served under the descriptor token like the usage report), the report's window and the queue's limits per
353
+ (agentRef, context). Raises ServiceError on a declaration a reader would treat as not declared."""
354
+ value = {"path": path, "windowSeconds": window_seconds, "limits": dict(limits) if isinstance(limits, dict) else limits}
355
+ problems = _declaration_problems(value)
356
+ if problems:
357
+ raise ServiceError("Load declaration: %s." % "; ".join(problems))
358
+ return {LOAD_EXTENSION_KEY: value}
@@ -700,8 +700,76 @@ def compose(draft: Dict[str, Any], files: Dict[str, bytes]) -> Tuple[Dict[str, A
700
700
  return pkg, notes
701
701
 
702
702
 
703
+ #: The agent's source manifest (contract DEC-0149, ``fabric-source/1``): when the folder carries one, ``pack`` writes
704
+ #: ``secrets[]`` from its secret slots and ``state[]`` from its store slots' paths, and FAC-SEM-160 holds them equal.
705
+ SOURCE_FILE = "fabric.source.json"
706
+
707
+
708
+ def _source_manifest(files: Dict[str, bytes]) -> Optional[Dict[str, Any]]:
709
+ """The folder's source manifest, or None when it has none; a refusal when it is no ``fabric-source/1`` object."""
710
+ if SOURCE_FILE not in files:
711
+ return None
712
+ source = _load_json(files[SOURCE_FILE], SOURCE_FILE)
713
+ if source.get("format") != "fabric-source/1":
714
+ raise PackageError("%s is not a fabric-source/1 manifest (its format member)." % SOURCE_FILE)
715
+ return source
716
+
717
+
718
+ def source_members(source: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
719
+ """``secrets[]`` from the secret slots (name, purpose, optional) and ``state[]`` from the store slots (path, purpose)."""
720
+ secrets: List[Dict[str, Any]] = []
721
+ state: List[Dict[str, Any]] = []
722
+ for slot in _objects(source.get("slots")):
723
+ if slot.get("kind") == "secret" and isinstance(slot.get("name"), str):
724
+ secrets.append(dict({"name": slot["name"], "purpose": slot.get("purpose")}, **({"optional": True} if slot.get("optional") is True else {})))
725
+ elif slot.get("kind") == "store" and isinstance(slot.get("path"), str):
726
+ state.append({"path": slot["path"], "purpose": slot.get("purpose")})
727
+ return secrets, state
728
+
729
+
730
+ def source_package_findings(source: Any, pkg: Any) -> List[Dict[str, str]]:
731
+ """FAC-SEM-160 (contract ``sourcePackageFindings``): the package's secrets[] are the source's secret slots, by name
732
+ and optional; its state[] paths are the store slots' paths when state[] holds any entry."""
733
+ if not isinstance(source, dict) or not isinstance(pkg, dict):
734
+ return []
735
+ out: List[Dict[str, str]] = []
736
+
737
+ def flag(at: str, message: str) -> None:
738
+ out.append({"code": "FAC-SEM-160", "instancePath": at, "message": message})
739
+ slots = _objects(source.get("slots"))
740
+ secrets = {s["name"]: s.get("optional") is True for s in slots if s.get("kind") == "secret" and isinstance(s.get("name"), str)}
741
+ stores = list(dict.fromkeys(s["path"] for s in slots if s.get("kind") == "store" and isinstance(s.get("path"), str)))
742
+ packaged = [(e, i) for i, e in enumerate(pkg.get("secrets") if isinstance(pkg.get("secrets"), list) else []) if isinstance(e, dict)]
743
+ names = {e.get("name") for e, _ in packaged}
744
+ for name in secrets:
745
+ if name not in names:
746
+ flag("/package/secrets", "the source's secret slot %s is not in the package's secrets[]" % name)
747
+ for entry, index in packaged:
748
+ name = entry.get("name")
749
+ if not isinstance(name, str) or name not in secrets:
750
+ flag("/package/secrets/%d/name" % index, "secrets[%d] is no secret slot of the source" % index)
751
+ elif secrets[name] != (entry.get("optional") is True):
752
+ flag("/package/secrets/%d/optional" % index, "secrets[%d] is %soptional in the source" % (index, "" if secrets[name] else "not "))
753
+ state = pkg.get("state")
754
+ if not isinstance(state, list) or not state:
755
+ return out
756
+ paths = {e.get("path") for e in _objects(state)}
757
+ for path in stores:
758
+ if path not in paths:
759
+ flag("/package/state", "a store slot's path of the source is not in the package's state[]")
760
+ for index, entry in ((i, e) for i, e in enumerate(state) if isinstance(e, dict)):
761
+ if not isinstance(entry.get("path"), str) or entry["path"] not in stores:
762
+ flag("/package/state/%d/path" % index, "state[%d] is no store slot of the source" % index)
763
+ return out
764
+
765
+
703
766
  def _all_problems(pkg: Dict[str, Any], files: Dict[str, bytes]) -> List[Dict[str, str]]:
704
767
  problems = [dict(p, rule="schema") for p in schema_problems(pkg)]
768
+ if SOURCE_FILE in files:
769
+ try:
770
+ problems += [dict(p, rule=p["code"]) for p in source_package_findings(_source_manifest(files), pkg)]
771
+ except PackageError as exc:
772
+ problems.append({"rule": "source", "instancePath": "/files", "message": str(exc)})
705
773
  problems += [dict(p, rule="path") for p in path_problems(pkg)]
706
774
  problems += [dict(p, rule=p["code"]) for p in package_findings(pkg)]
707
775
  problems += [{"rule": "folder", "problem": p["problem"], "instancePath": p["path"], "message": FOLDER_MESSAGES.get(p["problem"], p["problem"])}
@@ -716,6 +784,24 @@ FOLDER_MESSAGES = {
716
784
  }
717
785
 
718
786
 
787
+ def _secrets_lacking(draft: Dict[str, Any], secrets: List[Dict[str, Any]]) -> List[str]:
788
+ """The names a hand-written secrets[] declares that the source manifest has no secret slot for, in their order."""
789
+ have = {s["name"] for s in secrets}
790
+ declared = draft.get("secrets") if isinstance(draft.get("secrets"), list) else []
791
+ lacking: List[str] = []
792
+ for entry in declared:
793
+ name = entry.get("name") if isinstance(entry, dict) else None
794
+ if isinstance(name, str) and name not in have and name not in lacking:
795
+ lacking.append(name)
796
+ return lacking
797
+
798
+
799
+ def _lacking_message(lacking: List[str]) -> str:
800
+ return ("%s declares no secret slot for %s, which %s's secrets[] names: pack writes secrets[] from the manifest's "
801
+ "slots and never drops a declared secret. Add each as a slot of kind secret to %s (or take it out of "
802
+ "secrets[]), then pack again" % (SOURCE_FILE, ", ".join(lacking), PACKAGE_FILE, SOURCE_FILE))
803
+
804
+
719
805
  def pack(folder: Any, apply: bool = False) -> Dict[str, Any]:
720
806
  """Pack ``folder`` (see the module's doc). The answer names the digest, ``agentRef`` and every problem; with
721
807
  ``apply`` and no problem, ``fabric.package.json`` is rewritten."""
@@ -725,8 +811,24 @@ def pack(folder: Any, apply: bool = False) -> Dict[str, Any]:
725
811
  raise PackageError("Write %s in the folder first: schema, kind, id, version, publisher, licence and, for an agent, "
726
812
  "resultSignal and evals with its receipt. pack fills in agentRef, files[] and digest." % PACKAGE_FILE)
727
813
  draft = _load_json(files[PACKAGE_FILE], PACKAGE_FILE)
814
+ source = _source_manifest(files)
815
+ written: List[str] = []
816
+ refused: List[Dict[str, str]] = []
817
+ if source is not None: # DEC-0149: secrets[] and state[] come from the source manifest's slots
818
+ secrets, state = source_members(source)
819
+ lacking = _secrets_lacking(draft, secrets)
820
+ if lacking: # a declared secret is never dropped: the manifest gains its slot first
821
+ refused.append({"rule": "source", "instancePath": "/secrets", "message": _lacking_message(lacking)})
822
+ elif secrets or "secrets" in draft:
823
+ draft["secrets"] = secrets
824
+ written.append("secrets[]")
825
+ if state and draft.get("state") != []: # an empty state[] is a copy packed without state, kept
826
+ draft["state"] = state
827
+ written.append("state[]")
728
828
  pkg, notes = compose(draft, files)
729
- problems = _all_problems(pkg, files)
829
+ if written:
830
+ notes.append("%s written from %s" % (" and ".join(written), SOURCE_FILE))
831
+ problems = refused + _all_problems(pkg, files)
730
832
  suite = pkg.get("evals", {}).get("suite") if isinstance(pkg.get("evals"), dict) else None
731
833
  if isinstance(suite, str) and suite in files and any(p.get("instancePath") == "/evals/receipt/suite" for p in problems):
732
834
  notes.append("the suite packed is %s: run it again and write that receipt" % sha256_of(files[suite]))
@@ -1,8 +1,11 @@
1
1
  """peer_owner: which OS account owns each end of a loopback TCP connection.
2
2
 
3
- peer_owner 1.1.0+kit.3: Observatory's peer_owner 1.1.0, vendored into the Fabric service kit as `fabric_peer_owner` (layer L0;
4
- DEC-0154 HA-2, FAA-226) from Project Observatory's `observatory/engine/peer_owner.py` at
5
- commit f86f0622 (passioncode-ai/project-observatory-dashboard, the same licence). It differs
3
+ peer_owner 1.2.0+kit.5: Observatory's peer_owner 1.2.0, vendored into the Fabric service kit as `fabric_peer_owner` (layer L0;
4
+ DEC-0154 HA-2, FAA-226) from Project Observatory's `observatory/engine/peer_owner.py`, first at
5
+ commit f86f0622 (1.1.0) and read against commit 2180235c (1.2.0, passioncode-ai/project-observatory-dashboard, the
6
+ same licence): the kit's line names 1.2.0 since kit.5 (FAA-258), whose Linux rule kit.1 to kit.4 already carried
7
+ and the contract's CT-254 states (v0.1.0+2026-11-07.3). Observatory's 1.3.x (this account's own sockets through
8
+ libproc, the contract's DEC-0155 RT-2) is not in it yet (FAA-292). It differs
6
9
  from that file in this paragraph, in `Owner` and `Row` (NamedTuples here where it has
7
10
  frozen dataclasses: a dataclass with postponed annotations needs its module in sys.modules,
8
11
  and a service may load a kit file by its path without one), and in writing `Optional[X]`
@@ -11,7 +14,8 @@
11
14
  SID before the system's (a client running as root or SYSTEM owns root's listener), and in
12
15
  two Linux and selection rules: on Linux `server_end` reads an accepted connection's own row
13
16
  and, for one not accept()ed yet (printed with uid 0 and inode 0), the listener row on its
14
- local address (state 0A; Observatory's 1.2.0 reading), `peer` reads a client
17
+ local address (state 0A; 1.2.0's rule, where the kit names its reasons `not-accepted`, `several-rows` and `closed`
18
+ and Observatory `no-listener`, `listener-no-socket`, `listeners-disagree` and `no-socket`), `peer` reads a client
15
19
  row with inode 0 (a closed client) as unknown, and several rows of one owner are that owner;
16
20
  a row printed twice by a `/proc` read under churn counts once, and a `/proc` question that
17
21
  finds no row or several reads the table once more; ctypes is imported where it is used, so a
@@ -19,7 +23,8 @@
19
23
  macOS: the sysctl calibration also requires a child process's listener, an empty netstat reads as
20
24
  `TABLE_RESTRICTED`, and a caller may vouch for a listener with a pid it started (`pid_listens`,
21
25
  `parse_lsof_listeners`, `LSOF`, `CheckedHTTPConnection(vouch_pid=...)`, `own_connection(vouch_pid=...)`); since kit.3
22
- the child's port is waited on with `selectors` and read under one deadline (FAA-273).
26
+ the child's port is waited on with `selectors` and read under one deadline (FAA-273); since kit.4 a number is ASCII
27
+ digits only and a netstat with only its header reads as `TABLE_RESTRICTED` (FAA-273 items 3 and 4).
23
28
  A limit /proc sets: it carries a socket's creator and no pid, so a listening socket that root
24
29
  created and handed to another account (systemd socket activation with User=) reads as root's. Standard library only, no import of another kit module.
25
30
  The kit's tests are `test/test_fabric_peer_owner.py`, over `test/fixtures/peer-owner/`;
@@ -110,7 +115,7 @@ import time
110
115
  from typing import NamedTuple, Optional, Tuple
111
116
 
112
117
  # This file's own version, for projects that copy it; not the engine's (that is configuration.VERSION).
113
- PEER_OWNER_VERSION = "1.1.0+kit.3" # Observatory's 1.1.0 with the kit's differences above
118
+ PEER_OWNER_VERSION = "1.2.0+kit.5" # Observatory's 1.2.0 with the kit's differences above
114
119
 
115
120
  THIS_ACCOUNT = "this-account"
116
121
  ANOTHER_ACCOUNT = "another-account"
@@ -121,7 +126,8 @@ KINDS = (THIS_ACCOUNT, ANOTHER_ACCOUNT, SYSTEM, UNKNOWN, NONE)
121
126
 
122
127
  #: The most table bytes one read accepts. The spec of this module asked for 1 MB; measured,
123
128
  #: `pcblist_n` costs about 860 bytes a socket, so 1 MB would hold about 1,200 sockets and a busy
124
- #: developer machine passes that. 4 MiB holds about 4,800.
129
+ #: developer machine passes that. 4 MiB holds about 6,800 at the 616 bytes a socket measured on macOS 26
130
+ #: (0.74.0 product reading 11, FAA-260).
125
131
  MAX_TABLE_BYTES = 4 * 1024 * 1024
126
132
  #: Every subprocess this module starts is stopped after this many seconds.
127
133
  TIMEOUT_SECONDS = 2.0
@@ -231,6 +237,11 @@ def classify(uid: Optional[int], pid: Optional[int], *, method: str, own_uid: Op
231
237
 
232
238
  # -- parsers: pure functions over captured text or bytes (the tests' fixtures) ---------------------------------------
233
239
 
240
+ def _ascii_digits(text: str) -> bool:
241
+ """`[0-9]+` as the Node twin reads a number; `str.isdigit` also takes `٤` and `²` (FAA-273 item 4)."""
242
+ return text != "" and text.isascii() and text.isdigit()
243
+
244
+
234
245
  def parse_netstat(text: str) -> list[Row]:
235
246
  """`netstat -anv -p tcp` on macOS. Process names may hold spaces ("Some App:123"), so the
236
247
  `process:pid` column is what is left between the leading and the trailing columns, both
@@ -246,6 +257,10 @@ def parse_netstat(text: str) -> list[Row]:
246
257
  # "Local Address" and "Foreign Address" are two header words each and one data column each.
247
258
  leading = at - sum(1 for w in names[:at] if w == "Address")
248
259
  trailing = len(names) - at - 1
260
+ if not any(line.split()[:1] and line.split()[0].startswith("tcp") for line in lines[header + 1:]):
261
+ # The caller's own connection is in any table it can see: a header and no tcp row is the restricted table
262
+ # (FAA-273 item 3), not `no-row`.
263
+ raise TableUnreadable(f"{TABLE_RESTRICTED}: netstat printed only its header to this process")
249
264
  rows: list[Row] = []
250
265
  for line in lines[header + 1:]:
251
266
  tok = line.split()
@@ -259,7 +274,7 @@ def parse_netstat(text: str) -> list[Row]:
259
274
  pid = None
260
275
  if ":" in process:
261
276
  tail = process.rsplit(":", 1)[1]
262
- pid = int(tail) if tail.isdigit() else None
277
+ pid = int(tail) if _ascii_digits(tail) else None
263
278
  rows.append(Row(local, remote, tok[5] == "LISTEN", None, pid, tok[5] == "TIME_WAIT"))
264
279
  return rows
265
280
 
@@ -283,7 +298,7 @@ def parse_lsof_listeners(text: str) -> list[tuple[str, int]]:
283
298
  if not line.startswith("n") or ":" not in line:
284
299
  continue
285
300
  host, port = line[1:].rsplit(":", 1)
286
- if port.isdigit():
301
+ if _ascii_digits(port):
287
302
  found.append((norm_host(host.strip("[]")), int(port)))
288
303
  return found
289
304
 
@@ -291,7 +306,7 @@ def parse_lsof_listeners(text: str) -> list[tuple[str, int]]:
291
306
  def parse_ps_uid(text: str) -> Optional[int]:
292
307
  """`ps -o uid= -p PID`: one number, or nothing when the process is gone."""
293
308
  text = text.strip()
294
- return int(text) if text.isdigit() else None
309
+ return int(text) if _ascii_digits(text) else None
295
310
 
296
311
 
297
312
  def parse_proc_net_tcp(text: str, *, family: int, byteorder: Optional[str] = None) -> list[Row]:
@@ -540,7 +555,7 @@ def _child_listener_seen(rows_of) -> Optional[bool]:
540
555
  port = None
541
556
  try:
542
557
  line = _child_line(child, deadline)
543
- if line is None or not line.strip().isdigit():
558
+ if line is None or not _ascii_digits(line.strip()):
544
559
  return None
545
560
  port = int(line.strip())
546
561
  try:
@@ -794,7 +809,7 @@ def _owner_of(local: tuple[str, int], remote: tuple[str, int], server: bool = Fa
794
809
  # (inet_csk_listen_stop), so that listener answers. An accepted connection's own row carries the uid of the
795
810
  # process that accepted it (sock_graft): it answers itself, so a listener swapped after another account
796
811
  # accepted the client cannot pass (Project Observatory's 1.2.0 reading, board thread ced00f6e; the contract's
797
- # pending CT-254, FAA-258).
812
+ # CT-254, HA-2's Linux source, released in v0.1.0+2026-11-07.3).
798
813
  # A server row its owner already closed (an orphan) also has inode 0 and reads so: no process holds it, so
799
814
  # nothing sent to it reaches anyone (0.74.0 unit reading 6).
800
815
  return _one_owner(_listeners_of(rows, found[0].local), how, "not-accepted")
@@ -846,7 +861,7 @@ def server_end(sock: socket.socket) -> Owner:
846
861
  server's and whose remote address is this socket's own, which is the server process's
847
862
  accepted socket, so a listener swapped after `listen` cannot pass for the one that answers.
848
863
  On Linux a connection not yet accept()ed has no owner of its own (inode 0): its listener
849
- answers (the contract's pending CT-254, FAA-258)."""
864
+ answers (the contract's CT-254, HA-2's Linux source)."""
850
865
  try:
851
866
  own, server = sock.getsockname(), sock.getpeername()
852
867
  except OSError as exc:
@@ -20,7 +20,7 @@ PROTOCOL_REVISIONS: Dict[str, Tuple[str, ...]] = {
20
20
  "fabric-operator-channel": ("0.1",), "fabric-package": ("1",),
21
21
  "fabric-peer-share": ("1",), "fabric-peer-share-envelope": ("1",), "fabric-peer-share-report": ("1",), "fabric-project-comms": ("0.1",), "fabric-proposal": ("1",),
22
22
  "fabric-provider": ("0.1",), "fabric-relay": ("0.1",), "fabric-secret-envelope": ("1",), "fabric-service": ("0.1",),
23
- "fabric-settings-backup": ("1",), "heartbeat": ("1",), "learning-note": ("1",), "machine-report": ("1",), "mandate": ("1",), "memory": ("0.1",), "node-summary": ("1",),
23
+ "fabric-settings-backup": ("1",), "fabric-source": ("1",), "heartbeat": ("1",), "learning-note": ("1",), "machine-report": ("1",), "mandate": ("1",), "memory": ("0.1",), "node-summary": ("1",),
24
24
  "operator-source": ("1",), "org-adaptation": ("1",), "org-adaptation-receipt": ("1",), "org-authority": ("1",), "org-authority-draft": ("1",), "org-key-set": ("1",), "org-key-set-draft": ("1",),
25
25
  "org-membership": ("1",), "org-root-registration": ("1",), "pairing-message": ("1",),
26
26
  "peer-share-deletion": ("1",), "peer-share-frame": ("1",), "peer-share-revocation": ("1",),