@descryy/mcp 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/dist/bin/descry-mcp.js +30 -2
  2. package/dist/bin/descry-mcp.js.map +1 -1
  3. package/dist/browser/driver.d.ts +236 -0
  4. package/dist/browser/driver.d.ts.map +1 -0
  5. package/dist/browser/driver.js +70 -0
  6. package/dist/browser/driver.js.map +1 -0
  7. package/dist/browser/evidence.d.ts +47 -0
  8. package/dist/browser/evidence.d.ts.map +1 -0
  9. package/dist/browser/evidence.js +55 -0
  10. package/dist/browser/evidence.js.map +1 -0
  11. package/dist/browser/fake-driver.d.ts +58 -0
  12. package/dist/browser/fake-driver.d.ts.map +1 -0
  13. package/dist/browser/fake-driver.js +161 -0
  14. package/dist/browser/fake-driver.js.map +1 -0
  15. package/dist/browser/graph-write.d.ts +86 -0
  16. package/dist/browser/graph-write.d.ts.map +1 -0
  17. package/dist/browser/graph-write.js +135 -0
  18. package/dist/browser/graph-write.js.map +1 -0
  19. package/dist/browser/playwright-driver.d.ts +65 -0
  20. package/dist/browser/playwright-driver.d.ts.map +1 -0
  21. package/dist/browser/playwright-driver.js +359 -0
  22. package/dist/browser/playwright-driver.js.map +1 -0
  23. package/dist/browser/provider.d.ts +49 -0
  24. package/dist/browser/provider.d.ts.map +1 -0
  25. package/dist/browser/provider.js +28 -0
  26. package/dist/browser/provider.js.map +1 -0
  27. package/dist/browser/registry.d.ts +182 -0
  28. package/dist/browser/registry.d.ts.map +1 -0
  29. package/dist/browser/registry.js +215 -0
  30. package/dist/browser/registry.js.map +1 -0
  31. package/dist/browser/scenario-resolve.d.ts +62 -0
  32. package/dist/browser/scenario-resolve.d.ts.map +1 -0
  33. package/dist/browser/scenario-resolve.js +127 -0
  34. package/dist/browser/scenario-resolve.js.map +1 -0
  35. package/dist/browser/scenario-runner.d.ts +113 -0
  36. package/dist/browser/scenario-runner.d.ts.map +1 -0
  37. package/dist/browser/scenario-runner.js +315 -0
  38. package/dist/browser/scenario-runner.js.map +1 -0
  39. package/dist/browser/stack-parser.d.ts +43 -0
  40. package/dist/browser/stack-parser.d.ts.map +1 -0
  41. package/dist/browser/stack-parser.js +112 -0
  42. package/dist/browser/stack-parser.js.map +1 -0
  43. package/dist/browser/tool-support.d.ts +113 -0
  44. package/dist/browser/tool-support.d.ts.map +1 -0
  45. package/dist/browser/tool-support.js +181 -0
  46. package/dist/browser/tool-support.js.map +1 -0
  47. package/dist/disclosure-ledger.d.ts +38 -0
  48. package/dist/disclosure-ledger.d.ts.map +1 -0
  49. package/dist/disclosure-ledger.js +40 -0
  50. package/dist/disclosure-ledger.js.map +1 -0
  51. package/dist/index.d.ts +12 -1
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +10 -0
  54. package/dist/index.js.map +1 -1
  55. package/dist/protocol.d.ts +12 -3
  56. package/dist/protocol.d.ts.map +1 -1
  57. package/dist/protocol.js +12 -3
  58. package/dist/protocol.js.map +1 -1
  59. package/dist/render.d.ts +121 -10
  60. package/dist/render.d.ts.map +1 -1
  61. package/dist/render.js +96 -15
  62. package/dist/render.js.map +1 -1
  63. package/dist/runtime-registry.d.ts +63 -0
  64. package/dist/runtime-registry.d.ts.map +1 -0
  65. package/dist/runtime-registry.js +131 -0
  66. package/dist/runtime-registry.js.map +1 -0
  67. package/dist/scenarios/index.d.ts +4 -0
  68. package/dist/scenarios/index.d.ts.map +1 -0
  69. package/dist/scenarios/index.js +4 -0
  70. package/dist/scenarios/index.js.map +1 -0
  71. package/dist/scenarios/parse.d.ts +32 -0
  72. package/dist/scenarios/parse.d.ts.map +1 -0
  73. package/dist/scenarios/parse.js +199 -0
  74. package/dist/scenarios/parse.js.map +1 -0
  75. package/dist/scenarios/scenario.d.ts +115 -0
  76. package/dist/scenarios/scenario.d.ts.map +1 -0
  77. package/dist/scenarios/scenario.js +41 -0
  78. package/dist/scenarios/scenario.js.map +1 -0
  79. package/dist/scenarios/storage.d.ts +51 -0
  80. package/dist/scenarios/storage.d.ts.map +1 -0
  81. package/dist/scenarios/storage.js +93 -0
  82. package/dist/scenarios/storage.js.map +1 -0
  83. package/dist/server.d.ts.map +1 -1
  84. package/dist/server.js +11 -3
  85. package/dist/server.js.map +1 -1
  86. package/dist/session.d.ts +142 -2
  87. package/dist/session.d.ts.map +1 -1
  88. package/dist/session.js +266 -4
  89. package/dist/session.js.map +1 -1
  90. package/dist/tools/analyze.d.ts +41 -1
  91. package/dist/tools/analyze.d.ts.map +1 -1
  92. package/dist/tools/analyze.js +111 -9
  93. package/dist/tools/analyze.js.map +1 -1
  94. package/dist/tools/browser-click.d.ts +36 -0
  95. package/dist/tools/browser-click.d.ts.map +1 -0
  96. package/dist/tools/browser-click.js +117 -0
  97. package/dist/tools/browser-click.js.map +1 -0
  98. package/dist/tools/browser-close-session.d.ts +24 -0
  99. package/dist/tools/browser-close-session.d.ts.map +1 -0
  100. package/dist/tools/browser-close-session.js +63 -0
  101. package/dist/tools/browser-close-session.js.map +1 -0
  102. package/dist/tools/browser-fill.d.ts +49 -0
  103. package/dist/tools/browser-fill.d.ts.map +1 -0
  104. package/dist/tools/browser-fill.js +138 -0
  105. package/dist/tools/browser-fill.js.map +1 -0
  106. package/dist/tools/browser-navigate.d.ts +32 -0
  107. package/dist/tools/browser-navigate.d.ts.map +1 -0
  108. package/dist/tools/browser-navigate.js +103 -0
  109. package/dist/tools/browser-navigate.js.map +1 -0
  110. package/dist/tools/browser-run-scenario.d.ts +54 -0
  111. package/dist/tools/browser-run-scenario.d.ts.map +1 -0
  112. package/dist/tools/browser-run-scenario.js +228 -0
  113. package/dist/tools/browser-run-scenario.js.map +1 -0
  114. package/dist/tools/browser-save-scenario.d.ts +44 -0
  115. package/dist/tools/browser-save-scenario.d.ts.map +1 -0
  116. package/dist/tools/browser-save-scenario.js +298 -0
  117. package/dist/tools/browser-save-scenario.js.map +1 -0
  118. package/dist/tools/browser-snapshot.d.ts +60 -0
  119. package/dist/tools/browser-snapshot.d.ts.map +1 -0
  120. package/dist/tools/browser-snapshot.js +139 -0
  121. package/dist/tools/browser-snapshot.js.map +1 -0
  122. package/dist/tools/browser-start-session.d.ts +43 -0
  123. package/dist/tools/browser-start-session.d.ts.map +1 -0
  124. package/dist/tools/browser-start-session.js +272 -0
  125. package/dist/tools/browser-start-session.js.map +1 -0
  126. package/dist/tools/browser-type.d.ts +48 -0
  127. package/dist/tools/browser-type.d.ts.map +1 -0
  128. package/dist/tools/browser-type.js +136 -0
  129. package/dist/tools/browser-type.js.map +1 -0
  130. package/dist/tools/contracts.d.ts +15 -0
  131. package/dist/tools/contracts.d.ts.map +1 -1
  132. package/dist/tools/contracts.js +14 -3
  133. package/dist/tools/contracts.js.map +1 -1
  134. package/dist/tools/cross-pr.d.ts.map +1 -1
  135. package/dist/tools/cross-pr.js +1 -0
  136. package/dist/tools/cross-pr.js.map +1 -1
  137. package/dist/tools/git-diff.d.ts.map +1 -1
  138. package/dist/tools/git-diff.js +104 -4
  139. package/dist/tools/git-diff.js.map +1 -1
  140. package/dist/tools/git-history.d.ts.map +1 -1
  141. package/dist/tools/git-history.js +1 -0
  142. package/dist/tools/git-history.js.map +1 -1
  143. package/dist/tools/history.js +1 -1
  144. package/dist/tools/history.js.map +1 -1
  145. package/dist/tools/impact.d.ts +9 -0
  146. package/dist/tools/impact.d.ts.map +1 -1
  147. package/dist/tools/impact.js +4 -4
  148. package/dist/tools/impact.js.map +1 -1
  149. package/dist/tools/index.d.ts +12 -1
  150. package/dist/tools/index.d.ts.map +1 -1
  151. package/dist/tools/index.js +31 -0
  152. package/dist/tools/index.js.map +1 -1
  153. package/dist/tools/kit.d.ts +11 -1
  154. package/dist/tools/kit.d.ts.map +1 -1
  155. package/dist/tools/kit.js +3 -0
  156. package/dist/tools/kit.js.map +1 -1
  157. package/dist/tools/link-workspace.d.ts.map +1 -1
  158. package/dist/tools/link-workspace.js +13 -1
  159. package/dist/tools/link-workspace.js.map +1 -1
  160. package/dist/tools/mark-incident.d.ts +69 -0
  161. package/dist/tools/mark-incident.d.ts.map +1 -0
  162. package/dist/tools/mark-incident.js +212 -0
  163. package/dist/tools/mark-incident.js.map +1 -0
  164. package/dist/tools/observe-runtime.d.ts +292 -0
  165. package/dist/tools/observe-runtime.d.ts.map +1 -0
  166. package/dist/tools/observe-runtime.js +1192 -0
  167. package/dist/tools/observe-runtime.js.map +1 -0
  168. package/dist/tools/observe-tests.d.ts +125 -0
  169. package/dist/tools/observe-tests.d.ts.map +1 -0
  170. package/dist/tools/observe-tests.js +313 -0
  171. package/dist/tools/observe-tests.js.map +1 -0
  172. package/dist/tools/pr-analysis.d.ts +71 -13
  173. package/dist/tools/pr-analysis.d.ts.map +1 -1
  174. package/dist/tools/pr-analysis.js +55 -12
  175. package/dist/tools/pr-analysis.js.map +1 -1
  176. package/dist/tools/pre-push.d.ts +77 -0
  177. package/dist/tools/pre-push.d.ts.map +1 -0
  178. package/dist/tools/pre-push.js +250 -0
  179. package/dist/tools/pre-push.js.map +1 -0
  180. package/dist/tools/propagation.d.ts.map +1 -1
  181. package/dist/tools/propagation.js +1 -0
  182. package/dist/tools/propagation.js.map +1 -1
  183. package/dist/tools/questions.d.ts.map +1 -1
  184. package/dist/tools/questions.js +101 -14
  185. package/dist/tools/questions.js.map +1 -1
  186. package/dist/tools/refusal-fetch.d.ts.map +1 -1
  187. package/dist/tools/refusal-fetch.js +1 -0
  188. package/dist/tools/refusal-fetch.js.map +1 -1
  189. package/dist/tools/runtime-incident.d.ts +92 -0
  190. package/dist/tools/runtime-incident.d.ts.map +1 -0
  191. package/dist/tools/runtime-incident.js +144 -0
  192. package/dist/tools/runtime-incident.js.map +1 -0
  193. package/dist/tools/scope.d.ts.map +1 -1
  194. package/dist/tools/scope.js +1 -0
  195. package/dist/tools/scope.js.map +1 -1
  196. package/dist/tools/similar-incidents.d.ts +13 -0
  197. package/dist/tools/similar-incidents.d.ts.map +1 -1
  198. package/dist/tools/similar-incidents.js +22 -8
  199. package/dist/tools/similar-incidents.js.map +1 -1
  200. package/dist/tools/verification-status.d.ts +22 -0
  201. package/dist/tools/verification-status.d.ts.map +1 -1
  202. package/dist/tools/verification-status.js +52 -5
  203. package/dist/tools/verification-status.js.map +1 -1
  204. package/dist/tools/verify-claim.d.ts.map +1 -1
  205. package/dist/tools/verify-claim.js +5 -0
  206. package/dist/tools/verify-claim.js.map +1 -1
  207. package/package.json +16 -4
@@ -0,0 +1,1192 @@
1
+ /**
2
+ * `observe_runtime` — boot or attach to a real application, watch it, and write
3
+ * what was witnessed into the graph as R4 facts.
4
+ *
5
+ * **The second tool that writes, and the first that writes something no
6
+ * re-index can reproduce.** `analyze` re-derives the graph from source already
7
+ * on disk, so nothing it writes is a new claim about the world. This one runs
8
+ * a real process and records what it saw happen, which is the one accuracy
9
+ * source architecture §11B.3 calls *"the core technical moat"* and the one a
10
+ * purely static tool is structurally unable to reach: *"a purely static
11
+ * code-graph tool is capped at R3 permanently. It has no runtime."*
12
+ *
13
+ * ## What this composes, and what it invents
14
+ *
15
+ * It invents no mechanism. Every stage already existed, gate-verified, in
16
+ * `descry-runtime`, and every one of them was dormant — the whole point of
17
+ * `DEC-NEXT-mcp-runtime-dependency-boundary-for-r4-evidence`, which measured
18
+ * that `applyRuntimeObservations` had **zero production callers anywhere**,
19
+ * not in `descry-desktop` and not in `descry-runtime`'s own pipeline. Four
20
+ * shipped components in a row, and this tool is the wire between them:
21
+ *
22
+ * 1. `runInstrumentedExecution` (`@descryy/runtime-orchestrator`) spawns or
23
+ * attaches the declared services, starts the adapter's collectors, drains
24
+ * them for a stated window, and writes every item through `EvidenceStore`.
25
+ * 2. `correlateExecution` (`@descryy/runtime-evidence-correlation`) resolves
26
+ * each evidence item to the graph node it is *about* — the resolve-then-
27
+ * attribute pass. This answers identity, not edges.
28
+ * 3. `confirmObservedFrontendCaller` (`@descryy/runtime-graph-correlator`)
29
+ * turns a captured call-site stack plus a resolved endpoint into a
30
+ * `RuntimeEdgeObservation`, and
31
+ * 4. calls `applyRuntimeObservations` (`@descryy/core`) with it, which
32
+ * promotes, mints or contradicts the edge and writes the EARNED ledger.
33
+ *
34
+ * ## Why stage 3 exists rather than deriving edges from stage 2 directly
35
+ *
36
+ * `uat-phase-1-bug-fixes.md` Phase 2 describes step 2 as producing
37
+ * `RuntimeEdgeObservation[]`. It does not, and the difference is load-bearing
38
+ * rather than pedantic: `correlateExecution` returns
39
+ * `CorrelationAttribution`s — *(evidenceId, graphNodeId)* pairs saying which
40
+ * single node an observation is about. An edge needs **two** endpoints and a
41
+ * witnessed relationship between them, and manufacturing one by pairing up
42
+ * attributions that happen to co-occur in the same run would mint edges from
43
+ * temporal coincidence. That is precisely the wrong-direction failure rule 2
44
+ * exists to prevent, arriving through the one mechanism built to make the
45
+ * graph *more* trustworthy.
46
+ *
47
+ * So the observation comes from the one shipped producer that can honestly
48
+ * make one: a captured stack naming the caller, against an endpoint the same
49
+ * observation named. Both endpoints come from the observation itself. Every
50
+ * other correlated item is reported in the counts and produces no edge, which
51
+ * is a disclosed gap rather than a silent one.
52
+ *
53
+ * ## What this closure can actually witness today — measured, not assumed
54
+ *
55
+ * The wire is complete, and what it can carry changed when the runtime packages
56
+ * were published at 0.1.0 and pinned here — so this paragraph is the record of
57
+ * a limit that was real and is now lifted, kept rather than deleted because the
58
+ * shape of it recurs.
59
+ *
60
+ * **It used to be that no producer of the required pair — HTTP evidence
61
+ * carrying a call-site stack — was in this server's closure.** The browser
62
+ * network collector never has been. The outbound-fetch instrumentation in
63
+ * `@descryy/runtime-external-service-observation` existed, was proven in
64
+ * `descry-runtime`, and was not installed here at all. So a run resolved both
65
+ * kinds of node and wrote no edge, and `STANDING_NOTES` said so on every call
66
+ * because "no edge was written" and "nothing here could have written one" are
67
+ * different statements.
68
+ *
69
+ * **That second producer is now in the closure**, transitively through
70
+ * `@descryy/runtime-orchestrator`, which applies the adapter's
71
+ * `outboundHttpLaunch()` between the interpreter and the script so the client
72
+ * is patched before any application code can capture an unpatched one. Measured
73
+ * end to end in `observe-runtime-outbound.conformance.test.ts`: two real
74
+ * processes, 17 `EXTERNAL_REQUEST` items alongside 18 `BACKEND_LOG`, one
75
+ * `USES_API` edge minted at R4, and the first `strongly supported` reply this
76
+ * server has produced.
77
+ *
78
+ * **Two limits remain, and they are stated rather than inferred from a zero.**
79
+ * An *attached* service is not launched by Descry, so the instrumentation
80
+ * cannot be installed into it and its outbound calls carry no stack. And
81
+ * browser-side traffic still needs `@descryy/runtime-browser`, which is not
82
+ * here. Both are in `STANDING_NOTES`.
83
+ *
84
+ * The rule that outlives all of it: a disclosure about the closure is a fact
85
+ * about *this build*, not about what `descry-runtime` can do. The two came
86
+ * apart once already, when the cross-boundary lane landed and this file still
87
+ * claimed the capability was absent. Re-check it against the installed tree
88
+ * when the pins move, not against the source repository.
89
+ * Descry.
90
+ *
91
+ * ## Why no denial is ever emitted
92
+ *
93
+ * `applyRuntimeObservations` accepts `held: false`. Nothing here ever sends
94
+ * one, and `confirmObservedFrontendCaller`'s own header explains why: a run
95
+ * establishes that a call *happened*; it cannot establish that one did not,
96
+ * because a run exercises the paths it happens to take. Demoting a correct
97
+ * edge on the strength of a route this run did not visit would be worse than
98
+ * never running.
99
+ *
100
+ * ## Class and tier
101
+ *
102
+ * `action` — DEC-278's own test is *"can this call's effect change a later
103
+ * finding without the developer having said so"*, and this one spawns
104
+ * processes and writes R4 edges that cap every later reliability class
105
+ * differently. It is gated by the same two-call `confirmToken` handshake
106
+ * `questions` uses, and additionally by the environment profile's declared
107
+ * `safetyLevel` (DEC-270): booting a service is a **write** against the
108
+ * target, so a `readOnly` profile refuses. An all-attach run is genuinely
109
+ * read-only — `ServiceConfiguration.attach`'s own contract is that Descry
110
+ * never executes code in, or applies limits to, a process it did not spawn —
111
+ * so it is allowed under `readOnly`, and that distinction is stated in the
112
+ * disclosures rather than inferred silently.
113
+ *
114
+ * `evidence` — it reports what was witnessed and what was written. It draws no
115
+ * conclusion about the user's code; nothing here reads or writes a finding, a
116
+ * hypothesis or a category (RT-027).
117
+ *
118
+ * ## No new query tools
119
+ *
120
+ * None are needed and none are added. `impact`, `propagation` and the rest
121
+ * already read the `resolution` field, so an edge this tool promotes to R4
122
+ * becomes visible through every one of them on the next call, with no change
123
+ * to any of them.
124
+ */
125
+ import { mkdir } from "node:fs/promises";
126
+ import { dirname, isAbsolute, join } from "node:path";
127
+ import { independentSignalTypes } from "@descryy/ir";
128
+ import { buildGraph, counts, createConfirmedIncidentSource, persistGraph, } from "@descryy/core";
129
+ import { evaluateAction, validateProfile } from "@descryy/runtime-environment-profile";
130
+ import { correlateExecution } from "@descryy/runtime-evidence-correlation";
131
+ import { EvidenceStore } from "@descryy/runtime-evidence-store";
132
+ import { confirmObservedFrontendCaller } from "@descryy/runtime-graph-correlator";
133
+ import { runInstrumentedExecution } from "@descryy/runtime-orchestrator";
134
+ import { answer, optionalInteger, optionalString, ToolInputError, } from "./kit.js";
135
+ import { loadRuntimeAdapter, RuntimeAdapterLoadError } from "../runtime-registry.js";
136
+ import { writeConfirmedIncident } from "../session.js";
137
+ import { runtimeObservedIncident } from "./runtime-incident.js";
138
+ /** Where evidence lands when the call does not say. Beside the graph, not inside it. */
139
+ export const DEFAULT_EVIDENCE_RELATIVE_PATH = join(".descry", "evidence.db");
140
+ /** How long collectors are drained after the services report ready, when unstated. */
141
+ const DEFAULT_OBSERVE_MS = 5_000;
142
+ const DEFAULT_TIMEOUT_MS = 60_000;
143
+ const DEFAULT_READINESS_TIMEOUT_MS = 30_000;
144
+ const READINESS_KINDS = ["http", "tcp-port", "command"];
145
+ const SCHEMA = {
146
+ type: "object",
147
+ properties: {
148
+ profile: {
149
+ type: "object",
150
+ description: "The environment this run targets. Every field is declared by you and never inferred from " +
151
+ "any other (DEC-270): a profile named \"staging\" with safetyLevel \"readOnly\" is read-only, " +
152
+ "and a profile named \"local\" with safetyLevel \"readOnly\" is too.",
153
+ properties: {
154
+ name: { type: "string", description: "Free-form. Matched against no vocabulary anywhere." },
155
+ url: { type: "string", description: "The target's base URL. Must parse." },
156
+ safetyLevel: {
157
+ type: "string",
158
+ enum: ["readOnly", "write", "destructiveWithApproval"],
159
+ description: "Booting a service is a write against the target, so \"readOnly\" refuses a run that " +
160
+ "spawns anything. A run in which every service uses \"attach\" spawns nothing and is " +
161
+ "permitted under \"readOnly\".",
162
+ },
163
+ credentialRef: {
164
+ type: "string",
165
+ description: "An opaque key into a credential store — never the secret itself.",
166
+ },
167
+ mode: { type: "string", enum: ["localBooted", "localAttached", "remote", "production"] },
168
+ },
169
+ required: ["name", "url", "safetyLevel", "credentialRef", "mode"],
170
+ additionalProperties: false,
171
+ },
172
+ services: {
173
+ type: "object",
174
+ description: "One entry per service, keyed by the name evidence will be attributed to. At least one is " +
175
+ "required. Exactly one of \"command\" or \"attach\" per service.",
176
+ additionalProperties: {
177
+ type: "object",
178
+ properties: {
179
+ command: { type: "string", description: "How to start it. Omit when using \"attach\"." },
180
+ cwd: {
181
+ type: "string",
182
+ description: "The directory this service is started in. Relative paths resolve against the " +
183
+ "repository root. REQUIRED with \"command\". With \"attach\" it is required only " +
184
+ "for a \"command\" readiness check, which is an executable Descry runs in it; for " +
185
+ "an \"http\" or \"tcp-port\" check it is not needed at all, because nothing is " +
186
+ "started and Descry never executes code in a process it did not spawn.",
187
+ },
188
+ // The description below is the fix for the single most expensive
189
+ // ergonomic gap this tool has: three failed runs in one real
190
+ // investigation, all the same root cause wearing different clothes,
191
+ // and the explanation both times living in a source comment.
192
+ port: {
193
+ type: "integer",
194
+ description: "The port readiness checks against, and the two modes need opposite things from you. " +
195
+ "With \"attach\": REQUIRED whenever readiness is \"http\" or \"tcp-port\" — the target " +
196
+ "chose its port before Descry saw it, and nothing in a pid or a log path reveals which, " +
197
+ "so this is refused up front rather than guessed. With \"command\": omit to get an " +
198
+ "ephemeral port, which is passed to your command as PORT; set it only if your command " +
199
+ "hardcodes a port, and then it must be THAT port — a readiness check against a port " +
200
+ "your command did not bind fails while the service is perfectly healthy.",
201
+ },
202
+ dependsOn: {
203
+ type: "array",
204
+ items: { type: "string" },
205
+ description: "Service names that must be ready first. Declared, never inferred.",
206
+ },
207
+ env: { type: "object", additionalProperties: { type: "string" } },
208
+ attach: {
209
+ type: "object",
210
+ description: "Observe a process that is already running instead of spawning one. Descry never " +
211
+ "executes code in, signals, or applies resource limits to a process it did not spawn.",
212
+ properties: {
213
+ pid: { type: "integer" },
214
+ logFilePath: {
215
+ type: "string",
216
+ description: "A file the target already writes its stdout/stderr to.",
217
+ },
218
+ },
219
+ required: ["pid", "logFilePath"],
220
+ additionalProperties: false,
221
+ },
222
+ readiness: {
223
+ type: "object",
224
+ description: "Required per service — a run refuses rather than treat \"the process started\" as " +
225
+ "\"the service is up\". Only the three mechanisms expressible as JSON are offered here; " +
226
+ "\"log-pattern\" and \"custom-hook\" need a function and are not reachable through this " +
227
+ "tool, which is disclosed on every call rather than left to be discovered.",
228
+ properties: {
229
+ kind: { type: "string", enum: [...READINESS_KINDS] },
230
+ path: {
231
+ type: "string",
232
+ description: "kind \"http\": path appended to http://127.0.0.1:<resolved port>. Defaults to \"/\".",
233
+ },
234
+ expectedStatus: { type: "integer", description: "kind \"http\": defaults to any 2xx/3xx." },
235
+ host: { type: "string", description: "kind \"tcp-port\": defaults to 127.0.0.1." },
236
+ command: { type: "string", description: "kind \"command\": the executable to run." },
237
+ args: { type: "array", items: { type: "string" }, description: "kind \"command\"." },
238
+ timeoutMs: { type: "integer", description: `Defaults to ${DEFAULT_READINESS_TIMEOUT_MS}.` },
239
+ },
240
+ required: ["kind"],
241
+ additionalProperties: false,
242
+ },
243
+ },
244
+ required: ["readiness"],
245
+ additionalProperties: false,
246
+ },
247
+ },
248
+ adapter: {
249
+ type: "object",
250
+ description: "The runtime adapter to observe with, named as a module specifier and imported at run time. " +
251
+ "This server depends on none of descry-runtime's per-language runtime adapters by design, and " +
252
+ "names none of them anywhere — including here, which is why this description carries no " +
253
+ "example specifier. Install the one matching the service's runtime alongside this server and " +
254
+ "name its package here; descry-runtime publishes one runtime adapter package per supported " +
255
+ "runtime, and its README lists them.",
256
+ properties: {
257
+ module: { type: "string" },
258
+ export: {
259
+ type: "string",
260
+ description: "Defaults to the single export matching create*RuntimeAdapter. Two matches is an error, " +
261
+ "not a coin toss — name one here.",
262
+ },
263
+ options: { type: "object", description: "Passed to the factory. Adapter-specific and opaque here." },
264
+ },
265
+ required: ["module"],
266
+ additionalProperties: false,
267
+ },
268
+ scopeByService: {
269
+ type: "object",
270
+ description: "Service name → which repository its symbols resolve in. A service with no entry has its " +
271
+ "symbol evidence left alone and its name reported, never resolved against a repository " +
272
+ "nobody named. Defaults to this session's own repo for every declared service.",
273
+ additionalProperties: {
274
+ type: "object",
275
+ properties: {
276
+ repo: { type: "string" },
277
+ repoRoot: { type: "string", description: "Absolute on-disk root, so observed absolute paths translate exactly." },
278
+ cwd: { type: "string" },
279
+ },
280
+ required: ["repo"],
281
+ additionalProperties: false,
282
+ },
283
+ },
284
+ observeForMs: {
285
+ type: "integer",
286
+ description: `How long to drain collector output after the services are up. Defaults to ${DEFAULT_OBSERVE_MS}. ` +
287
+ "There is no \"the application is done\" signal at this layer — a server runs until stopped — " +
288
+ "so you state the observation window rather than this tool guessing at one.",
289
+ },
290
+ timeoutMs: { type: "integer", description: `Whole-execution budget. Defaults to ${DEFAULT_TIMEOUT_MS}.` },
291
+ environmentTier: {
292
+ type: "string",
293
+ enum: ["tier-0-ci-attached", "tier-1-preview", "tier-2-container", "tier-2b-api-only", "tier-3-static-only"],
294
+ description: "Recorded on the execution. Defaults to \"tier-2-container\" and is deliberately not derived " +
295
+ "from \"profile.mode\" — DEC-270's rule is that a declared field is declared, not inferred " +
296
+ "from a neighbouring one.",
297
+ },
298
+ fidelityLevel: {
299
+ type: "integer",
300
+ enum: [1, 2, 3, 4],
301
+ description: "1 rule-aware stub · 2 real code + disposable DB · 3 real code + redacted recordings · " +
302
+ "4 real staging. Defaults to 2. Not derived from anything else, same reason as environmentTier.",
303
+ },
304
+ evidencePath: {
305
+ type: "string",
306
+ description: `Where the evidence database lives. Defaults to ${DEFAULT_EVIDENCE_RELATIVE_PATH} under the repository root.`,
307
+ },
308
+ confirmToken: {
309
+ type: "string",
310
+ description: "The token returned by an unconfirmed call. This tool performs nothing without it: the first " +
311
+ "call describes what running would do and returns a token, and only a second call presenting " +
312
+ "that exact token runs anything — with the arguments frozen when the token was minted, never " +
313
+ "whatever the second call supplies.",
314
+ },
315
+ },
316
+ required: ["profile", "services", "adapter"],
317
+ additionalProperties: false,
318
+ };
319
+ // ---------------------------------------------------------------------------
320
+ // Argument reading. Hand-written, same reasoning as `kit.ts`'s own readers.
321
+ // ---------------------------------------------------------------------------
322
+ function asRecord(value, what) {
323
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
324
+ throw new ToolInputError(`"${what}" must be an object`);
325
+ }
326
+ return value;
327
+ }
328
+ function readProfile(args) {
329
+ const raw = asRecord(args["profile"], "profile");
330
+ const field = (key) => {
331
+ const value = raw[key];
332
+ if (typeof value !== "string")
333
+ throw new ToolInputError(`"profile.${key}" must be a string`);
334
+ return value;
335
+ };
336
+ const candidate = {
337
+ name: field("name"),
338
+ url: field("url"),
339
+ safetyLevel: field("safetyLevel"),
340
+ credentialRef: field("credentialRef"),
341
+ mode: field("mode"),
342
+ };
343
+ const errors = validateProfile(candidate);
344
+ if (errors.length > 0) {
345
+ // The profile package's own error codes, verbatim — this tool adds no
346
+ // interpretation to a validation it did not perform.
347
+ throw new ToolInputError(`"profile" is not valid: ${errors.join(", ")}`);
348
+ }
349
+ return candidate;
350
+ }
351
+ function readAdapterSpec(args) {
352
+ const raw = asRecord(args["adapter"], "adapter");
353
+ const module = raw["module"];
354
+ if (typeof module !== "string" || module === "") {
355
+ throw new ToolInputError('"adapter.module" is required and must be a non-empty string');
356
+ }
357
+ const exportName = raw["export"];
358
+ if (exportName !== undefined && typeof exportName !== "string") {
359
+ throw new ToolInputError('"adapter.export" must be a string');
360
+ }
361
+ const options = raw["options"];
362
+ if (options !== undefined && (typeof options !== "object" || options === null)) {
363
+ throw new ToolInputError('"adapter.options" must be an object');
364
+ }
365
+ return {
366
+ module,
367
+ ...(typeof exportName === "string" ? { export: exportName } : {}),
368
+ ...(options === undefined ? {} : { options: options }),
369
+ };
370
+ }
371
+ function readServices(args, repoPath) {
372
+ const raw = asRecord(args["services"], "services");
373
+ const names = Object.keys(raw);
374
+ if (names.length === 0)
375
+ throw new ToolInputError('"services" must declare at least one service');
376
+ return names.map((name) => {
377
+ const entry = asRecord(raw[name], `services.${name}`);
378
+ const command = entry["command"];
379
+ const attachRaw = entry["attach"];
380
+ if ((command === undefined) === (attachRaw === undefined)) {
381
+ throw new ToolInputError(`services.${name} must declare exactly one of "command" or "attach" — ` +
382
+ (command === undefined ? "it declares neither" : "it declares both"));
383
+ }
384
+ if (command !== undefined && typeof command !== "string") {
385
+ throw new ToolInputError(`"services.${name}.command" must be a string`);
386
+ }
387
+ // Read once, here, because two separate rules below need it: whether a
388
+ // `cwd` is required at all, and whether an attached service must state its
389
+ // port.
390
+ const readinessRaw = entry["readiness"];
391
+ const readinessKind = typeof readinessRaw === "object" && readinessRaw !== null && !Array.isArray(readinessRaw)
392
+ ? readinessRaw["kind"]
393
+ : undefined;
394
+ // `cwd` is required exactly where something will be executed in it, and
395
+ // nowhere else. Descry spawns nothing for an attached service and never
396
+ // executes code in a process it did not spawn, so for an attached service
397
+ // with an `http` or `tcp-port` check the value is inert — demanding it
398
+ // makes the caller invent a path that changes nothing. A `command` check
399
+ // *is* executed, in this directory, so it still needs one.
400
+ //
401
+ // The runtime contract's `ServiceConfiguration.cwd` is non-optional, so
402
+ // something must be supplied downstream either way; the repository root is
403
+ // the inert choice, and it is inert precisely because nothing runs there
404
+ // on this path.
405
+ // (`DEC-388`.)
406
+ const cwdRaw = entry["cwd"];
407
+ if (cwdRaw !== undefined && (typeof cwdRaw !== "string" || cwdRaw === "")) {
408
+ throw new ToolInputError(`"services.${name}.cwd" must be a non-empty string`);
409
+ }
410
+ if (cwdRaw === undefined && attachRaw === undefined) {
411
+ throw new ToolInputError(`"services.${name}.cwd" is required and must be a non-empty string: it is the directory ` +
412
+ `"${name}" is started in.`);
413
+ }
414
+ if (cwdRaw === undefined && attachRaw !== undefined && readinessKind === "command") {
415
+ throw new ToolInputError(`"services.${name}.cwd" is required when "${name}" uses "attach" with a "command" ` +
416
+ "readiness check: the check is an executable Descry runs, and it runs in this " +
417
+ "directory. Attaching needs no cwd otherwise — nothing is started.");
418
+ }
419
+ const cwd = cwdRaw ?? repoPath;
420
+ const port = entry["port"];
421
+ if (port !== undefined && (typeof port !== "number" || !Number.isInteger(port) || port < 0)) {
422
+ throw new ToolInputError(`"services.${name}.port" must be a non-negative integer`);
423
+ }
424
+ const dependsOn = entry["dependsOn"];
425
+ if (dependsOn !== undefined &&
426
+ (!Array.isArray(dependsOn) || dependsOn.some((d) => typeof d !== "string"))) {
427
+ throw new ToolInputError(`"services.${name}.dependsOn" must be an array of strings`);
428
+ }
429
+ const env = entry["env"];
430
+ if (env !== undefined) {
431
+ const record = asRecord(env, `services.${name}.env`);
432
+ for (const [key, value] of Object.entries(record)) {
433
+ if (typeof value !== "string") {
434
+ throw new ToolInputError(`"services.${name}.env.${key}" must be a string`);
435
+ }
436
+ }
437
+ }
438
+ let attach;
439
+ if (attachRaw !== undefined) {
440
+ const a = asRecord(attachRaw, `services.${name}.attach`);
441
+ const pid = a["pid"];
442
+ const logFilePath = a["logFilePath"];
443
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) {
444
+ throw new ToolInputError(`"services.${name}.attach.pid" must be a positive integer`);
445
+ }
446
+ if (typeof logFilePath !== "string" || logFilePath === "") {
447
+ throw new ToolInputError(`"services.${name}.attach.logFilePath" is required`);
448
+ }
449
+ attach = { pid, logFilePath };
450
+ // A spawned service gets an ephemeral port allocated for it; an attached
451
+ // one cannot, because the target chose its own port before Descry
452
+ // existed and nothing in a pid or a log path reveals which. The
453
+ // controller's refusal to guess is deliberate and right — but expressed
454
+ // as `port ?? 0`, it surfaces to a caller as a readiness check timing
455
+ // out against port 0 some seconds later, with the actual explanation
456
+ // living in a source comment they cannot see. Twice in one real
457
+ // investigation that cost a full failed run to rediscover. So it is
458
+ // refused here, by name, before anything starts.
459
+ //
460
+ // Only for the two checks that resolve a port. A "command" check runs an
461
+ // executable and never asks where the service listens, so demanding a
462
+ // port for it would be a second wrong answer in the other direction.
463
+ if ((readinessKind === "http" || readinessKind === "tcp-port") && port === undefined) {
464
+ throw new ToolInputError(`"services.${name}.port" is required when "${name}" uses "attach" with a ` +
465
+ `"${readinessKind}" readiness check: the check needs a port and an attached target's ` +
466
+ "port cannot be allocated or inferred — it is whatever the already-running process " +
467
+ "bound. State it, or use a \"command\" readiness check, which needs none.");
468
+ }
469
+ }
470
+ const configuration = {
471
+ ...(typeof command === "string" ? { command } : {}),
472
+ cwd: isAbsolute(cwd) ? cwd : join(repoPath, cwd),
473
+ ...(port === undefined ? {} : { port: port }),
474
+ ...(dependsOn === undefined ? {} : { dependsOn: dependsOn }),
475
+ ...(env === undefined ? {} : { env: env }),
476
+ ...(attach === undefined ? {} : { attach }),
477
+ };
478
+ return {
479
+ name,
480
+ configuration,
481
+ readiness: readReadiness(entry["readiness"], name, configuration.cwd),
482
+ attached: attach !== undefined,
483
+ };
484
+ });
485
+ }
486
+ /**
487
+ * The JSON→`ReadinessCheck` mapping, and the two mechanisms it cannot express.
488
+ *
489
+ * `log-pattern` needs a `read()` closing over the `ManagedProcess` the
490
+ * controller owns, and `custom-hook` is a function outright. Neither survives a
491
+ * JSON boundary, and inventing a string-shaped stand-in for either would offer
492
+ * a mechanism that silently is not the one named. They are absent from the
493
+ * schema's enum and stated in the disclosures instead — rule 7, honest
494
+ * degradation, applied to a capability rather than to a result.
495
+ */
496
+ function readReadiness(raw, service, cwd) {
497
+ const entry = asRecord(raw, `services.${service}.readiness`);
498
+ const kind = entry["kind"];
499
+ if (typeof kind !== "string" || !READINESS_KINDS.includes(kind)) {
500
+ throw new ToolInputError(`"services.${service}.readiness.kind" must be one of: ${READINESS_KINDS.join(", ")}`);
501
+ }
502
+ const timeoutMs = entry["timeoutMs"];
503
+ if (timeoutMs !== undefined &&
504
+ (typeof timeoutMs !== "number" || !Number.isInteger(timeoutMs) || timeoutMs < 1)) {
505
+ throw new ToolInputError(`"services.${service}.readiness.timeoutMs" must be a positive integer`);
506
+ }
507
+ // Every field is validated **here**, not inside `checks`. The controller does
508
+ // not call `checks()` until the service has already spawned, so a bad
509
+ // argument validated lazily would surface as a failed run with a live process
510
+ // to clean up rather than as a rejected call that started nothing — and
511
+ // `ToolInputError`'s whole contract is that it is something the caller can
512
+ // fix before anything happens.
513
+ const path = typeof entry["path"] === "string" ? entry["path"] : "/";
514
+ const expectedStatus = entry["expectedStatus"];
515
+ if (expectedStatus !== undefined && typeof expectedStatus !== "number") {
516
+ throw new ToolInputError(`"services.${service}.readiness.expectedStatus" must be a number`);
517
+ }
518
+ const host = typeof entry["host"] === "string" ? entry["host"] : "127.0.0.1";
519
+ const command = entry["command"];
520
+ const commandArgs = entry["args"];
521
+ if (kind === "command") {
522
+ if (typeof command !== "string" || command === "") {
523
+ throw new ToolInputError(`"services.${service}.readiness.command" is required for kind "command"`);
524
+ }
525
+ if (commandArgs !== undefined &&
526
+ (!Array.isArray(commandArgs) || commandArgs.some((a) => typeof a !== "string"))) {
527
+ throw new ToolInputError(`"services.${service}.readiness.args" must be an array of strings`);
528
+ }
529
+ }
530
+ const checks = (info) => {
531
+ if (kind === "http") {
532
+ return [
533
+ {
534
+ kind: "http",
535
+ url: `http://127.0.0.1:${String(info.port)}${path.startsWith("/") ? path : `/${path}`}`,
536
+ ...(typeof expectedStatus === "number" ? { expectedStatus } : {}),
537
+ },
538
+ ];
539
+ }
540
+ if (kind === "tcp-port") {
541
+ return [{ kind: "tcp-port", host, port: info.port }];
542
+ }
543
+ return [
544
+ {
545
+ kind: "command",
546
+ command: command,
547
+ ...(commandArgs === undefined ? {} : { args: commandArgs }),
548
+ cwd,
549
+ },
550
+ ];
551
+ };
552
+ return {
553
+ checks,
554
+ timeoutMs: typeof timeoutMs === "number" ? timeoutMs : DEFAULT_READINESS_TIMEOUT_MS,
555
+ };
556
+ }
557
+ function readScopes(args, declared, fallback) {
558
+ const scopes = {};
559
+ for (const service of declared)
560
+ scopes[service.name] = fallback;
561
+ const raw = args["scopeByService"];
562
+ if (raw === undefined)
563
+ return scopes;
564
+ for (const [name, value] of Object.entries(asRecord(raw, "scopeByService"))) {
565
+ const entry = asRecord(value, `scopeByService.${name}`);
566
+ const repo = entry["repo"];
567
+ if (typeof repo !== "string" || repo === "") {
568
+ throw new ToolInputError(`"scopeByService.${name}.repo" is required and must be a non-empty string`);
569
+ }
570
+ const repoRoot = entry["repoRoot"];
571
+ const cwd = entry["cwd"];
572
+ if (repoRoot !== undefined && typeof repoRoot !== "string") {
573
+ throw new ToolInputError(`"scopeByService.${name}.repoRoot" must be a string`);
574
+ }
575
+ if (cwd !== undefined && typeof cwd !== "string") {
576
+ throw new ToolInputError(`"scopeByService.${name}.cwd" must be a string`);
577
+ }
578
+ scopes[name] = {
579
+ repo,
580
+ ...(repoRoot === undefined ? {} : { repoRoot }),
581
+ ...(cwd === undefined ? {} : { cwd }),
582
+ };
583
+ }
584
+ return scopes;
585
+ }
586
+ // ---------------------------------------------------------------------------
587
+ // The run
588
+ // ---------------------------------------------------------------------------
589
+ const EMPTY_WRITE = {
590
+ promoted: [],
591
+ created: [],
592
+ confirmed: [],
593
+ refused: [],
594
+ contradictions: [],
595
+ staleR4: [],
596
+ };
597
+ /**
598
+ * The disclosure every call carries, whatever it did.
599
+ *
600
+ * Stated unconditionally rather than only when it bites: a caller who does not
601
+ * know that `log-pattern` readiness is unreachable here will write a
602
+ * `tcp-port` check that passes the instant the socket binds and read the
603
+ * resulting empty evidence as "the service produced nothing".
604
+ */
605
+ const STANDING_NOTES = [
606
+ "Readiness here offers only the three mechanisms JSON can state — http, tcp-port and command. " +
607
+ "log-pattern and custom-hook need a function and are unreachable through this tool; a run that " +
608
+ "needs one of those is not degraded here, it is unsupported here.",
609
+ "Edges are written only where an observation named both endpoints itself: a captured call-site " +
610
+ "stack resolving to a function, against an endpoint the same observation named. Every other " +
611
+ "correlated evidence item resolves a node and produces no edge, which is a gap in what this run " +
612
+ "could prove rather than evidence that no such edge exists.",
613
+ "That combination — HTTP evidence carrying a call-site stack — is produced here by the outbound-fetch " +
614
+ "instrumentation in @descryy/runtime-external-service-observation, which this server installs into a " +
615
+ "spawned service before any application code runs. So an observed outbound call CAN write this edge, " +
616
+ "and a run that makes none writes none: the backend collectors attach a stack to log and error lines " +
617
+ "(resolving a function) and none to inbound HTTP traffic (resolving an endpoint), so a service that " +
618
+ "never calls out resolves both kinds of node and still writes nothing. A zero here means this run " +
619
+ "observed no outbound call it could attribute, not that no such call exists in your code.",
620
+ "Two things this still cannot witness. An ATTACHED service is not launched by Descry, so the client " +
621
+ "instrumentation cannot be installed into it and its outbound calls carry no call-site stack. And " +
622
+ "browser-side requests need @descryy/runtime-browser, which is not in this server's closure — the " +
623
+ "browser_* tools refuse with a named remedy rather than appearing to work.",
624
+ "No denial is ever recorded. A run establishes that a call happened; it cannot establish that one " +
625
+ "did not, because it exercises only the paths it took. Nothing here demotes an edge.",
626
+ ];
627
+ /**
628
+ * Said on every run that attaches, because it explains an absence that
629
+ * otherwise reads as a Descry defect — and did, in a real investigation, until
630
+ * it was measured.
631
+ *
632
+ * A request fired partway through an observation window did not appear in the
633
+ * evidence, and appeared in the log file afterwards. The obvious reading is
634
+ * that observation stopped early. It did not: reproduced end to end against a
635
+ * real attached service, a request fired at t≈5s of a 12s window landed in the
636
+ * evidence store, timestamped correctly. What actually happens is one layer
637
+ * out — a process whose stdout is redirected to a file is block-buffered, not
638
+ * line-buffered, because the descriptor is not a terminal. Measured directly:
639
+ * three lines written over 0.6s were still entirely absent from the file three
640
+ * seconds later, and arrived only when the process flushed.
641
+ *
642
+ * Nothing in Descry can see those bytes; they are in the target's own
643
+ * userspace buffer. So this is not a gap to close, it is a boundary to state —
644
+ * and stating it is what separates "we did not see it" from "it was not
645
+ * there", which is the whole difference this tool exists to preserve.
646
+ *
647
+ * Written for any redirected process, naming no language or framework: the
648
+ * behaviour is the C standard library's, and a note scoped to one ecosystem
649
+ * would invite one more such note per ecosystem.
650
+ */
651
+ const ATTACH_BUFFERING_NOTE = "Attaching reads a file the target writes; it can only see what the target has already flushed " +
652
+ "there. A process whose output is redirected to a file is usually block-buffered rather than " +
653
+ "line-buffered — its own runtime holds whole lines in a userspace buffer, invisible from outside, " +
654
+ "until the buffer fills or the process flushes. Output produced during this window may therefore " +
655
+ "arrive in the file after the window closed and be absent here, which is a fact about the " +
656
+ "target's buffering and not evidence that it did nothing. Run the target with its output " +
657
+ "unbuffered or line-buffered if the timing matters.";
658
+ /**
659
+ * Every argument this tool takes, read and validated in one place.
660
+ *
661
+ * Extracted from `run` so that `describeAction` can call it too. That is the
662
+ * whole point of the extraction: an `action` tool's first call is the one a
663
+ * caller makes *before* it has a token, and therefore the only cheap place to
664
+ * learn its arguments are wrong. Minting a token, and a paragraph describing
665
+ * an action, for a declaration that cannot possibly run asks a developer to
666
+ * confirm something that was never going to happen — and delivers the real
667
+ * refusal on the second call, after the confirmation.
668
+ *
669
+ * Nothing here touches the filesystem, spawns anything or reads the graph: it
670
+ * is argument reading and nothing else, which is what makes it safe to run at
671
+ * mint time, outside the call budget.
672
+ *
673
+ * Ruled in `documents/decisions-inbox/DEC-387.md`.
674
+ *
675
+ * Exported for its own test: several of the argument rules are conditional and
676
+ * cheaper to assert directly than through a full run.
677
+ */
678
+ export function readArguments(args, repoPath) {
679
+ const profile = readProfile(args);
680
+ const adapterSpec = readAdapterSpec(args);
681
+ const declared = readServices(args, repoPath);
682
+ const observeForMs = optionalInteger(args, "observeForMs", 1) ?? DEFAULT_OBSERVE_MS;
683
+ const timeoutMs = optionalInteger(args, "timeoutMs", 1) ?? DEFAULT_TIMEOUT_MS;
684
+ const fidelityRaw = optionalInteger(args, "fidelityLevel", 1) ?? 2;
685
+ if (fidelityRaw > 4)
686
+ throw new ToolInputError('"fidelityLevel" must be 1, 2, 3 or 4');
687
+ const environmentTier = optionalString(args, "environmentTier") ?? "tier-2-container";
688
+ const evidenceArg = optionalString(args, "evidencePath");
689
+ const evidencePath = evidenceArg === undefined
690
+ ? join(repoPath, DEFAULT_EVIDENCE_RELATIVE_PATH)
691
+ : isAbsolute(evidenceArg)
692
+ ? evidenceArg
693
+ : join(repoPath, evidenceArg);
694
+ return { profile, adapterSpec, declared, observeForMs, timeoutMs, fidelityRaw, environmentTier, evidencePath };
695
+ }
696
+ async function run(args, ctx) {
697
+ const session = ctx.session;
698
+ const { profile, adapterSpec, declared, observeForMs, timeoutMs, fidelityRaw, environmentTier, evidencePath } = readArguments(args, session.repoPath);
699
+ const notes = [...STANDING_NOTES];
700
+ // Added on the refusal paths too, deliberately. A caller who attaches and is
701
+ // then refused for some unrelated reason will fix that reason and attach
702
+ // again; telling them about the buffering only on the success path means
703
+ // telling them after the run whose result it would have explained.
704
+ if (declared.some((service) => service.attached))
705
+ notes.push(ATTACH_BUFFERING_NOTE);
706
+ const base = session.provider().baseStamp();
707
+ const refuse = (headline, data = {}) => answer({
708
+ headline,
709
+ state: "refused",
710
+ nameLevel: true,
711
+ // Nothing ran, so nothing was resolved. A refusal reporting the tier its
712
+ // successful path would have reached is the leaked-default this repo's
713
+ // own UAT already caught once elsewhere.
714
+ resolutionFloor: 0,
715
+ commitSha: base.commitSha,
716
+ graphBuiltAt: base.graphBuiltAt,
717
+ irSchemaVersion: base.irSchemaVersion,
718
+ commitSpread: base.commitSpread,
719
+ notes,
720
+ data: {
721
+ executionId: null,
722
+ executionState: null,
723
+ adapterLanguage: null,
724
+ evidencePath,
725
+ services: [],
726
+ evidenceByType: {},
727
+ correlation: null,
728
+ wrote: EMPTY_WRITE,
729
+ ...data,
730
+ },
731
+ });
732
+ // --- the safety gate ------------------------------------------------------
733
+ // Spawning is a write against the target; attaching is not (Descry never
734
+ // executes code in, signals, or applies limits to a process it did not
735
+ // spawn). So the action's shape depends on what was declared, and the
736
+ // profile's declared level decides — never the profile's name.
737
+ const spawns = declared.some((service) => !service.attached);
738
+ const action = { write: spawns, destructive: false };
739
+ const decision = evaluateAction(profile, action);
740
+ if (decision !== "allow") {
741
+ return refuse(`Profile "${profile.name}" declares safetyLevel "${profile.safetyLevel}", which does not permit ` +
742
+ `${spawns ? "spawning a service" : "this run"}. Nothing was started and nothing was written. ` +
743
+ (spawns
744
+ ? "A run in which every service uses \"attach\" spawns nothing and is permitted under readOnly."
745
+ : ""));
746
+ }
747
+ if (!spawns) {
748
+ notes.push("Every declared service is attached to rather than spawned, so this run started nothing and " +
749
+ "applied no resource, filesystem or network policy to any process — Descry does not constrain " +
750
+ "a process it did not spawn.");
751
+ }
752
+ // --- the graph must exist -------------------------------------------------
753
+ // Correlation resolves evidence against this graph. Against an empty one it
754
+ // resolves nothing, and reporting that as a clean run with no findings would
755
+ // be the exact "empty means broken" collapse the five states exist to stop.
756
+ const driver = session.store().driver;
757
+ const stored = counts(driver);
758
+ if (stored.nodes === 0) {
759
+ return refuse("This repository has no graph yet, so there is nothing for a run's evidence to be resolved " +
760
+ "against. Run analyze first — an observation that cannot name a node cannot become a fact.");
761
+ }
762
+ // --- the adapter ----------------------------------------------------------
763
+ ctx.progress(`Loading runtime adapter ${adapterSpec.module}`);
764
+ let adapter;
765
+ try {
766
+ adapter = await loadRuntimeAdapter(adapterSpec);
767
+ }
768
+ catch (error) {
769
+ if (error instanceof RuntimeAdapterLoadError) {
770
+ notes.push("This server depends on none of descry-runtime's language adapters by design, so the adapter " +
771
+ "must be installed alongside it and named in the call. Nothing was started.");
772
+ return refuse(error.message);
773
+ }
774
+ throw error;
775
+ }
776
+ notes.push(`Observed with the runtime adapter for "${adapter.language}", loaded from ${adapterSpec.module}.`);
777
+ const root = await session.root();
778
+ const scopes = readScopes(args, declared, {
779
+ repo: root.repo,
780
+ repoRoot: root.absolutePath,
781
+ });
782
+ const services = {};
783
+ const readiness = {};
784
+ for (const service of declared) {
785
+ services[service.name] = service.configuration;
786
+ readiness[service.name] = service.readiness;
787
+ }
788
+ const configuration = {
789
+ environmentTier: environmentTier,
790
+ fidelityLevel: fidelityRaw,
791
+ timeoutMs,
792
+ services,
793
+ };
794
+ await mkdir(dirname(evidencePath), { recursive: true });
795
+ const evidenceStore = new EvidenceStore({ path: evidencePath });
796
+ try {
797
+ ctx.progress(`Running ${declared.length} service(s), observing for ${String(observeForMs)}ms`);
798
+ const execution = await runInstrumentedExecution({
799
+ execution: {
800
+ application: root.repo,
801
+ repository: root.repo,
802
+ commit: root.commitSha,
803
+ configuration,
804
+ },
805
+ runOptions: { readiness },
806
+ adapter,
807
+ store: evidenceStore,
808
+ observeForMs,
809
+ });
810
+ const observed = describeServices(execution.execution.processes, declared);
811
+ const evidenceByType = tally(execution.evidence);
812
+ if (execution.validationError !== null) {
813
+ // The controller refused before spawning anything. That is a fact about
814
+ // the declaration, not about the application — no evidence, no
815
+ // correlation, and emphatically not "the service is clean".
816
+ return refuse(`The execution refused to start: ${execution.validationError}. Nothing was spawned, no ` +
817
+ "evidence was collected, and no graph edge was written.", {
818
+ executionState: execution.execution.state,
819
+ adapterLanguage: adapter.language,
820
+ services: observed,
821
+ evidenceByType,
822
+ });
823
+ }
824
+ ctx.progress(`Correlating ${String(execution.evidence.length)} evidence item(s) against the graph`);
825
+ const pass = correlateExecution({
826
+ store: evidenceStore,
827
+ driver,
828
+ executionId: execution.execution.executionId,
829
+ scopeByService: scopes,
830
+ });
831
+ const correlation = {
832
+ considered: pass.considered,
833
+ skipped: pass.skipped,
834
+ attributed: pass.attributed.length,
835
+ refused: pass.refusals.length,
836
+ unscopedServices: pass.unscopedServices,
837
+ harnessErrors: pass.harnessErrors.map((e) => `${e.detail} (${String(e.occurrences)}×)`),
838
+ };
839
+ if (pass.unscopedServices.length > 0) {
840
+ // Two different sentences, because they call for two different actions
841
+ // and the first one used to be printed for both. A named service with no
842
+ // scope is something the caller can fix by supplying one. The `(no
843
+ // service)` sentinel is not: `correlateExecution` looks a scope up by
844
+ // `evidence.service`, and no collector in this dependency closure stamps
845
+ // one — measured by the conformance run, which prints a real V8 stack
846
+ // resolving to a real graph node and watches it go unasked. Telling a
847
+ // caller to name a scope they have no key for would send them after a
848
+ // fix that does not exist, which is the honest-degradation rule failing
849
+ // in the one place it acts.
850
+ const named = pass.unscopedServices.filter((s) => s !== "(no service)");
851
+ const anonymous = pass.unscopedServices.length - named.length;
852
+ if (named.length > 0) {
853
+ notes.push(`Symbol evidence from ${named.join(", ")} was left unresolved: no scope named which ` +
854
+ "repository those symbols belong to, and resolving them against a repository nobody " +
855
+ "named would resolve the wrong one's identically-named file. Supply " +
856
+ '"scopeByService" for those services to have them resolved.');
857
+ }
858
+ if (anonymous > 0) {
859
+ notes.push("Some evidence carried a source location but no service name, so no scope could be " +
860
+ "looked up for it and its symbols were never resolved. This is not a missing argument: " +
861
+ "nothing in this server's runtime closure stamps a service name onto collector " +
862
+ "evidence, so there is no key a caller could supply a scope under. The items are " +
863
+ "counted as skipped rather than dropped, and what they would have resolved to is " +
864
+ "unknown rather than absent.");
865
+ }
866
+ }
867
+ if (pass.harnessErrors.length > 0) {
868
+ notes.push(`${String(pass.harnessErrors.length)} correlation failure(s) were the machinery breaking rather ` +
869
+ "than a resolver honestly declining — each is written into the evidence stream as a " +
870
+ "COLLECTOR_ERROR, and the counts below are correspondingly incomplete.");
871
+ }
872
+ // A service that died is a witnessed failure, and this run is the only
873
+ // thing that will ever have seen it. Recorded before the edge write so a
874
+ // failure in one does not silently cost the other.
875
+ // See `runtime-incident.ts` for why an EXCEPTION alone is not an incident.
876
+ const incident = runtimeObservedIncident({
877
+ repo: root.repo,
878
+ repoRoot: root.absolutePath,
879
+ runId: execution.execution.executionId,
880
+ services: observed,
881
+ exceptionLocations: execution.evidence
882
+ .filter((e) => e.eventType === "EXCEPTION")
883
+ .map((e) => ({ file: e.sourceLocation?.file ?? null })),
884
+ exceptionTexts: execution.evidence
885
+ .filter((e) => e.eventType === "EXCEPTION")
886
+ .map((e) => (typeof e.payload === "string" ? e.payload : JSON.stringify(e.payload)))
887
+ .map((text) => text.split("\n")[0] ?? "")
888
+ .filter((line) => line !== ""),
889
+ });
890
+ if (incident !== null) {
891
+ ctx.progress("Recording the observed failure as an incident");
892
+ await writeConfirmedIncident(session.repoPath, incident);
893
+ const projected = await createConfirmedIncidentSource({
894
+ repo: root.repo,
895
+ incidents: [...(session.config.confirmedIncidents ?? []), incident],
896
+ }).emit({ root });
897
+ persistGraph(driver, [projected], buildGraph([projected], {
898
+ nodeExists: (id) => session.provider().node(id) !== undefined,
899
+ }));
900
+ notes.push(`A service died during this run (${incident.summary}), so it was recorded as an incident ` +
901
+ `correlated to ${String(incident.files.length)} file(s) this repository owns, and written ` +
902
+ "durably to .descry/config.json — a run is gone once the process exits. The correlation " +
903
+ "is every file an exception stack named during the run, which is not a claim about the " +
904
+ "cause: nothing here knows which exception killed the process, and choosing the last one " +
905
+ "would be recency standing in for causality.");
906
+ }
907
+ ctx.progress("Writing observed edges into the graph");
908
+ const wrote = writeObservations({
909
+ driver,
910
+ pass,
911
+ evidenceStore,
912
+ repo: root.repo,
913
+ repoRoot: root.absolutePath,
914
+ runId: execution.execution.executionId,
915
+ commitSha: root.commitSha,
916
+ });
917
+ const written = wrote.promoted.length + wrote.created.length;
918
+ if (written === 0) {
919
+ notes.push("No edge was written. Either no observation carried a call-site stack that resolved to a " +
920
+ "function this graph holds, or every one it did carry was already at R4. Both are real " +
921
+ "outcomes of this run, and neither says the graph's existing edges are wrong.");
922
+ }
923
+ const headline = `Observed ${String(execution.evidence.length)} evidence item(s) across ${String(declared.length)} ` +
924
+ `service(s); ${String(pass.attributed.length)} resolved to graph nodes; ` +
925
+ `${String(wrote.promoted.length)} edge(s) promoted to R4 and ${String(wrote.created.length)} minted at R4.`;
926
+ return answer({
927
+ headline,
928
+ // Deliberately not `empty` when nothing was witnessed: `empty` is a claim
929
+ // about the population, and "this run took no path that exercised the
930
+ // code" is not "this code does nothing". The counts say what happened.
931
+ state: "ok",
932
+ nameLevel: true,
933
+ // R4 unconditionally, and honestly so — every fact in `wrote` was
934
+ // witnessed at runtime, which is the one resolution level that does not
935
+ // rest on inference (DEC-115). The correlated-but-unwritten items are
936
+ // not claimed here at all; they resolved a node and asserted nothing, so
937
+ // a run that wrote nothing reports R0 rather than borrowing the tier its
938
+ // successful path would have reached.
939
+ resolutionFloor: (written > 0 ? 4 : 0),
940
+ // G3's precondition and G4's `E`, declared because this run actually
941
+ // watched something. Only on a path where evidence really came back: a
942
+ // completed run that collected nothing witnessed nothing, and saying
943
+ // otherwise would assert a precondition on an empty array.
944
+ //
945
+ // Note this is deliberately *not* gated on `written > 0`. Whether an
946
+ // edge could be written is a fact about the graph path, and rule 3
947
+ // already caps the category through `resolutionFloor` just above — a
948
+ // run that saw three channels and wrote no edge is reported
949
+ // `unconfirmed` by the cap, not by pretending it saw nothing. Two
950
+ // separate facts, each stated once.
951
+ ...(execution.evidence.length === 0
952
+ ? {}
953
+ : { runtimeEvidence: { independentSignalTypes: witnessedSignalTypes(execution.evidence) } }),
954
+ commitSha: base.commitSha,
955
+ graphBuiltAt: base.graphBuiltAt,
956
+ irSchemaVersion: base.irSchemaVersion,
957
+ commitSpread: base.commitSpread,
958
+ notes,
959
+ data: {
960
+ executionId: execution.execution.executionId,
961
+ executionState: execution.execution.state,
962
+ adapterLanguage: adapter.language,
963
+ evidencePath,
964
+ services: observed,
965
+ evidenceByType,
966
+ correlation,
967
+ wrote,
968
+ },
969
+ });
970
+ }
971
+ finally {
972
+ evidenceStore.close();
973
+ }
974
+ }
975
+ /**
976
+ * One row per **declared** service, not one per spawned process — a service
977
+ * that never started must appear with `started: false` rather than vanish from
978
+ * the list, which is the difference between "it ran and did nothing" and "it
979
+ * never ran".
980
+ */
981
+ function describeServices(processes, declared) {
982
+ const byService = new Map();
983
+ for (const handle of processes) {
984
+ if (handle.serviceName !== null)
985
+ byService.set(handle.serviceName, handle);
986
+ }
987
+ return declared.map((service) => {
988
+ const handle = byService.get(service.name);
989
+ return {
990
+ service: service.name,
991
+ started: handle !== undefined,
992
+ attached: service.attached,
993
+ port: handle?.port ?? null,
994
+ exitedAt: handle?.exitedAt ?? null,
995
+ exitCode: handle?.exitCode ?? null,
996
+ signal: handle?.signal ?? null,
997
+ };
998
+ });
999
+ }
1000
+ function tally(evidence) {
1001
+ const byType = {};
1002
+ for (const item of evidence)
1003
+ byType[item.eventType] = (byType[item.eventType] ?? 0) + 1;
1004
+ return byType;
1005
+ }
1006
+ /**
1007
+ * One evidence row onto one of `@descryy/ir`'s six `RUNTIME_SIGNAL_TYPES`, or
1008
+ * `null`.
1009
+ *
1010
+ * **Decided by `eventType`, with `source` consulted only where the event type
1011
+ * is genuinely ambiguous** — an exception can come off a browser console or a
1012
+ * backend process, and nothing but the collector says which. The obvious
1013
+ * alternative, reading `source` alone, is wrong and was measured to be wrong
1014
+ * rather than reasoned about: every row this repository's own conformance run
1015
+ * produces carries `source: "backend-process"`, and `EVIDENCE_SOURCES` also
1016
+ * has a `backend-log` value that nothing in the shipped dependency closure
1017
+ * emits. A source-driven table would have counted zero channels on every real
1018
+ * run while passing a hand-built test — the exact shape of failure that gets
1019
+ * caught by running the thing.
1020
+ *
1021
+ * **Everything not listed returns `null` and is counted as nothing.** That is
1022
+ * `RuntimeEvidence`'s own rule, not caution added here: `TEST_*` is excluded
1023
+ * by design (its evidentiary weight is `M`'s, never double-counted as a
1024
+ * channel too), harness actions are Descry driving the application rather than
1025
+ * observing it, process lifecycle is a fact about the process rather than
1026
+ * about its behaviour, and a collector or version-mismatch error is a fact
1027
+ * about the run. A kind this table has not ruled on must fail closed, because
1028
+ * the alternative — mapping it to the nearest-looking channel — raises `E`,
1029
+ * and therefore the reported category, with nobody having decided that it
1030
+ * should.
1031
+ */
1032
+ function signalOf(item) {
1033
+ switch (item.eventType) {
1034
+ case "CONSOLE_MESSAGE":
1035
+ return "browser-console";
1036
+ case "NETWORK_REQUEST":
1037
+ case "NETWORK_RESPONSE":
1038
+ case "HTTP_ERROR":
1039
+ case "WEBSOCKET_CLOSED":
1040
+ return "network";
1041
+ case "SCREENSHOT":
1042
+ case "VIDEO":
1043
+ return "browser-visual";
1044
+ case "BACKEND_LOG":
1045
+ return "backend-log";
1046
+ case "DATABASE_QUERY":
1047
+ return "database";
1048
+ case "EXTERNAL_REQUEST":
1049
+ return "external-service";
1050
+ // The ambiguous pair, and the only place `source` decides: a thrown error
1051
+ // reaches Descry through whichever collector saw it, and that collector is
1052
+ // the channel.
1053
+ case "EXCEPTION":
1054
+ case "STACK_TRACE":
1055
+ return item.source === "browser-console" ? "browser-console" : "backend-log";
1056
+ default:
1057
+ return null;
1058
+ }
1059
+ }
1060
+ /**
1061
+ * `E` for this run — how many of the six channels it actually saw.
1062
+ *
1063
+ * The count itself is `@descryy/ir`'s `independentSignalTypes`, deliberately:
1064
+ * the clamp to six and the drop of `null` signals are that function's rules,
1065
+ * and a second implementation of them here is a second place for the
1066
+ * vocabulary to drift. This function's only job is the translation above.
1067
+ *
1068
+ * Exported for its own test — the mapping decides whether a witnessed answer
1069
+ * reads `strongly supported` or `unconfirmed`, which is too load-bearing to be
1070
+ * asserted only through a category two layers downstream.
1071
+ */
1072
+ export function witnessedSignalTypes(evidence) {
1073
+ return independentSignalTypes(evidence.map((item) => ({ signal: signalOf(item), detail: item.eventType })));
1074
+ }
1075
+ /**
1076
+ * The R4 write, and the one join this tool performs itself.
1077
+ *
1078
+ * For every evidence item the correlation pass resolved to an endpoint, if that
1079
+ * same item also carried a call-site stack, ask `confirmObservedFrontendCaller`
1080
+ * whether the stack names a function — and when it does, it writes. Both
1081
+ * endpoints of the resulting edge come from the one observation; nothing here
1082
+ * pairs two separate items together. See the module header for why that
1083
+ * restraint is the whole design rather than a limitation of it.
1084
+ */
1085
+ function writeObservations(input) {
1086
+ const promoted = [];
1087
+ const created = [];
1088
+ const confirmed = [];
1089
+ const refused = [];
1090
+ const contradictions = [];
1091
+ const staleR4 = [];
1092
+ // One evidence item can be attributed twice (an endpoint and a second naming
1093
+ // the same line carries). Keyed on both so the same (evidence, endpoint) pair
1094
+ // is never confirmed twice within one run.
1095
+ const seen = new Set();
1096
+ for (const attribution of input.pass.attributed) {
1097
+ // `endpoint` only, and `log-text-endpoint` deliberately excluded. That
1098
+ // family fires when a backend log LINE mentions a route and carries its
1099
+ // own stack frame — which establishes that the function logged about the
1100
+ // endpoint, not that it called it. The commonest real shape is a handler
1101
+ // logging "GET /invoices -> 500", and that function SERVES the endpoint
1102
+ // rather than USING it, so an edge minted from it could point the wrong
1103
+ // way. Rule 2: a wrong edge corrupts diff scoping, impact scores and
1104
+ // root-cause traversal; a missing one is a disclosed gap. Omitted.
1105
+ if (attribution.family !== "endpoint")
1106
+ continue;
1107
+ const key = `${attribution.evidenceId}::${attribution.graphNodeId}`;
1108
+ if (seen.has(key))
1109
+ continue;
1110
+ seen.add(key);
1111
+ const evidence = input.evidenceStore.getById(attribution.evidenceId);
1112
+ if (evidence === null || evidence.stackTrace === null)
1113
+ continue;
1114
+ const outcome = confirmObservedFrontendCaller(input.driver, {
1115
+ endpointNodeId: attribution.graphNodeId,
1116
+ stackTrace: evidence.stackTrace,
1117
+ repo: input.repo,
1118
+ runId: input.runId,
1119
+ commitSha: input.commitSha,
1120
+ repoRoot: input.repoRoot,
1121
+ });
1122
+ promoted.push(...outcome.confirmation.promoted);
1123
+ created.push(...outcome.confirmation.created);
1124
+ confirmed.push(...outcome.confirmation.confirmed);
1125
+ refused.push(...outcome.confirmation.refused.map((r) => r.reason));
1126
+ contradictions.push(...outcome.confirmation.contradictions.map((c) => c.detail));
1127
+ staleR4.push(...outcome.confirmation.staleR4.map((s) => s.detail));
1128
+ }
1129
+ return { promoted, created, confirmed, refused, contradictions, staleR4 };
1130
+ }
1131
+ /**
1132
+ * What confirming this call would do, in the caller's own terms.
1133
+ *
1134
+ * Always returns a sentence — unlike `questions`, whose unconfirmed shape is a
1135
+ * genuine pure read of the question queue, there is no argument to this tool
1136
+ * that makes it not run anything. Every valid call spawns or attaches, and
1137
+ * every one of them can write.
1138
+ */
1139
+ /**
1140
+ * §7's `willDo`, and — since `readArguments` is the first thing it does — the
1141
+ * point where an invalid call is refused.
1142
+ *
1143
+ * Returns a string on every valid call rather than ever returning `undefined`:
1144
+ * `undefined` means "this particular call has nothing to confirm", and there
1145
+ * is no such call here. Every accepted declaration boots or attaches to
1146
+ * something and may write durable R4 facts.
1147
+ *
1148
+ * Reads the *parsed* services rather than the raw object, so the sentence a
1149
+ * developer confirms is built from the same values the run will use — which
1150
+ * is also what makes "spawn" and "attach" here mean exactly what
1151
+ * `readServices` decided they mean, rather than a second, looser guess at the
1152
+ * same distinction.
1153
+ */
1154
+ function describeAction(args, ctx) {
1155
+ const { declared } = readArguments(args, ctx.session.repoPath);
1156
+ const spawned = declared.filter((service) => !service.attached).map((service) => service.name);
1157
+ const attached = declared.filter((service) => service.attached).map((service) => service.name);
1158
+ const parts = [];
1159
+ if (spawned.length > 0)
1160
+ parts.push(`start ${String(spawned.length)} service(s) (${spawned.join(", ")})`);
1161
+ if (attached.length > 0) {
1162
+ parts.push(`attach to ${String(attached.length)} already-running service(s) (${attached.join(", ")})`);
1163
+ }
1164
+ return (`${parts.join(" and ")}, observe them, and write any edge the run witnesses into this ` +
1165
+ "repository's graph at R4 — a durable fact that raises every later finding resting on it to " +
1166
+ "reliability class A, and that survives re-indexing. Nothing is ever demoted or deleted.");
1167
+ }
1168
+ export const observeRuntimeTool = {
1169
+ name: "observe_runtime",
1170
+ class: "action",
1171
+ tier: "evidence",
1172
+ version: "1.0.0",
1173
+ title: "Run the application and record what was observed",
1174
+ description: "Boot or attach to the declared services, watch them with a runtime adapter, resolve what was " +
1175
+ "observed against this repository's graph, and record the edges the run actually witnessed at R4 " +
1176
+ "— the one resolution level static analysis cannot reach. Promoted and newly minted edges become " +
1177
+ "visible through impact, propagation and every other tool immediately, with no second call: they " +
1178
+ "already read the resolution field. Running takes a two-call confirmation — the first call " +
1179
+ "performs nothing and returns a token describing what it would do; call again with " +
1180
+ "\"confirmToken\" to actually run it. The environment profile's declared safetyLevel is checked " +
1181
+ "before anything starts: booting is a write, attaching is not. " +
1182
+ "BEFORE CALLING: this observes an application you can already run — it does not help you get to " +
1183
+ "a runnable state, and that boundary is real rather than apologetic. Its dependencies must be up, " +
1184
+ "its environment set, and its migrations applied, all by you. One trap worth stating because it " +
1185
+ "is invisible: overriding some environment variables does not isolate a run from the " +
1186
+ "application's own configuration file — anything you did not explicitly override is still read " +
1187
+ "from it, including values naming environments you did not intend to touch.",
1188
+ inputSchema: SCHEMA,
1189
+ run,
1190
+ describeAction,
1191
+ };
1192
+ //# sourceMappingURL=observe-runtime.js.map